feat(web): make active tables sortable
DH V2 CI / API · typecheck, tests, build (push) Successful in 53s
Production dependency audit / API · production dependencies (push) Successful in 19s
Production dependency audit / WEB · production dependencies (push) Successful in 16s
DH V2 CI / WEB · typecheck, build (push) Successful in 1m34s
DH V2 CI / Docker / migrations / production images (push) Successful in 2m49s
DH V2 CI / Promote verified main to deploy (push) Successful in 3s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 7m22s

This commit is contained in:
ChatGPT DH
2026-09-16 19:20:41 -03:00
parent fa6ba7f31e
commit 1beba14c3b
36 changed files with 253 additions and 55 deletions
+119
View File
@@ -0,0 +1,119 @@
import {
Children,
cloneElement,
isValidElement,
useMemo,
useState,
type ReactElement,
type ReactNode,
type TableHTMLAttributes,
} from 'react';
type SortDirection = 'ascending' | 'descending';
type SortState = { column: number; direction: SortDirection } | null;
type ElementProps = Record<string, unknown> & { children?: ReactNode; colSpan?: number };
function textValue(node: ReactNode): string {
if (node == null || typeof node === 'boolean') return '';
if (typeof node === 'string' || typeof node === 'number') return String(node);
if (Array.isArray(node)) return node.map(textValue).join(' ');
if (!isValidElement(node)) return '';
const props = node.props as ElementProps;
const explicit = props['data-sort-value'];
if (explicit != null) return String(explicit);
return textValue(props.children);
}
function dateValue(value: string): number | null {
const normalized = value.replace(/\s+/g, ' ').trim();
const local = normalized.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})(?:,?\s+(\d{1,2}):(\d{2}))?/);
if (local) {
const dayText = local[1]!;
const monthText = local[2]!;
const yearText = local[3]!;
const hourText = local[4] ?? '0';
const minuteText = local[5] ?? '0';
const year = Number(yearText.length === 2 ? `20${yearText}` : yearText);
return Date.UTC(year, Number(monthText) - 1, Number(dayText), Number(hourText), Number(minuteText));
}
if (/^\d{4}-\d{2}-\d{2}/.test(normalized)) {
const parsed = Date.parse(normalized);
return Number.isNaN(parsed) ? null : parsed;
}
return null;
}
function compareValues(leftRaw: string, rightRaw: string): number {
const left = leftRaw.replace(/\s+/g, ' ').trim();
const right = rightRaw.replace(/\s+/g, ' ').trim();
if (!left && !right) return 0;
if (!left) return 1;
if (!right) return -1;
const leftDate = dateValue(left);
const rightDate = dateValue(right);
if (leftDate != null && rightDate != null) return leftDate - rightDate;
const numericPattern = /^[-+]?\d+(?:[.,]\d+)?$/;
if (numericPattern.test(left) && numericPattern.test(right)) {
return Number(left.replace(',', '.')) - Number(right.replace(',', '.'));
}
return left.localeCompare(right, 'es-AR', { numeric: true, sensitivity: 'base' });
}
function rowValue(row: ReactNode, column: number): string {
if (!isValidElement(row)) return '';
const cells = Children.toArray((row.props as ElementProps).children);
return textValue(cells[column]);
}
function sortBody(body: ReactElement, sort: SortState): ReactElement {
if (!sort) return body;
const props = body.props as ElementProps;
const rows = Children.toArray(props.children).map((row, index) => ({ row, index }));
rows.sort((left, right) => {
const compared = compareValues(rowValue(left.row, sort.column), rowValue(right.row, sort.column));
if (compared === 0) return left.index - right.index;
return sort.direction === 'ascending' ? compared : -compared;
});
return cloneElement(body, undefined, rows.map((entry) => entry.row));
}
export function SortableTable({ children, ...props }: TableHTMLAttributes<HTMLTableElement>) {
const [sort, setSort] = useState<SortState>(null);
const renderedChildren = useMemo(() => Children.toArray(children).map((child) => {
if (!isValidElement(child)) return child;
if (child.type === 'tbody') return sortBody(child, sort);
if (child.type !== 'thead') return child;
const headProps = child.props as ElementProps;
const rows = Children.toArray(headProps.children).map((row) => {
if (!isValidElement(row) || row.type !== 'tr') return row;
const rowProps = row.props as ElementProps;
const headers = Children.toArray(rowProps.children).map((header, column) => {
if (!isValidElement(header) || header.type !== 'th') return header;
const headerProps = header.props as ElementProps;
const label = textValue(headerProps.children).trim();
const sortable = Boolean(label) && headerProps['data-sortable'] !== false && (headerProps.colSpan ?? 1) === 1;
if (!sortable) return header;
const active = sort?.column === column;
const direction = active ? sort.direction : undefined;
const nextDirection: SortDirection = active && sort.direction === 'ascending' ? 'descending' : 'ascending';
const headerElement = header as ReactElement<Record<string, unknown>>;
return cloneElement(headerElement, {
'aria-sort': direction ?? 'none',
className: [String(headerProps.className ?? ''), 'sortable-th', active ? 'is-sorted' : ''].filter(Boolean).join(' '),
}, <button type="button" className="sortable-th-button" onClick={() => setSort({ column, direction: nextDirection })}>
<span>{headerProps.children}</span>
<span className="sort-indicator" aria-hidden="true">{active ? (sort.direction === 'ascending' ? '▲' : '▼') : '↕'}</span>
</button>);
});
return cloneElement(row, undefined, headers);
});
return cloneElement(child, undefined, rows);
}), [children, sort]);
return <table {...props}>{renderedChildren}</table>;
}