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
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:
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user