Assessor_2.0/src/components/ResizableResultTable/ResizableResultTable.tsx

139 lines
6.1 KiB
TypeScript

import { useId, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react';
import { useResultTableSelection } from './ResultTableSelection';
const MIN_COLUMN_WIDTH = 72;
const MAX_COLUMN_WIDTH = 720;
interface ColumnDrag {
pointerId: number;
columnIndex: number;
startX: number;
startWidths: number[];
}
interface Props {
headers: ReactNode[];
children?: ReactNode;
enabled?: boolean;
className?: string;
sourceSheetId?: string;
}
function clampColumnWidth(width: number): number {
return Math.max(MIN_COLUMN_WIDTH, Math.min(MAX_COLUMN_WIDTH, Math.round(width)));
}
function columnWidthStyle(width: number): CSSProperties {
return { width: `${width}px` };
}
function measuredColumnWidths(table: HTMLTableElement, columnCount: number): number[] {
const headers = Array.from(table.tHead?.rows[0]?.cells ?? []);
return Array.from({ length: columnCount }, (_, index) => clampColumnWidth(headers[index]?.getBoundingClientRect().width || MIN_COLUMN_WIDTH));
}
/**
* Misst den ungebrochenen Inhalt der Zielspalte. `white-space: nowrap` wird nur
* fuer den Messaugenblick gesetzt und sofort wieder entfernt; importierter
* Inhalt wird dabei weder kopiert noch protokolliert.
*/
function fittedColumnWidth(table: HTMLTableElement, columnIndex: number): number {
const cells = Array.from(table.rows)
.map(row => row.cells[columnIndex])
.filter((cell): cell is HTMLTableCellElement => Boolean(cell));
let preferred = MIN_COLUMN_WIDTH;
for (const cell of cells) {
const previousWhiteSpace = cell.style.whiteSpace;
const previousWidth = cell.style.width;
cell.style.whiteSpace = 'nowrap';
cell.style.width = 'auto';
preferred = Math.max(preferred, cell.scrollWidth + 2);
cell.style.whiteSpace = previousWhiteSpace;
cell.style.width = previousWidth;
}
return clampColumnWidth(preferred);
}
export function ResizableResultTable({ headers, children, enabled = false, className = 'rich-text-table', sourceSheetId }: Props) {
const tableId = useId();
const selection = useResultTableSelection();
const columnCount = headers.length;
const tableRef = useRef<HTMLTableElement>(null);
const dragRef = useRef<ColumnDrag | null>(null);
const [columnWidths, setColumnWidths] = useState<number[]>([]);
const appliedWidths = enabled && columnWidths.length === columnCount ? columnWidths : [];
const tableStyle: CSSProperties | undefined = appliedWidths.length
? { tableLayout: 'fixed', width: `${appliedWidths.reduce((sum, width) => sum + width, 0)}px` }
: undefined;
const startResize = (columnIndex: number) => (event: ReactPointerEvent<HTMLSpanElement>) => {
if (!enabled || !tableRef.current) return;
const startWidths = measuredColumnWidths(tableRef.current, columnCount);
dragRef.current = { pointerId: event.pointerId, columnIndex, startX: event.clientX, startWidths };
setColumnWidths(startWidths);
event.currentTarget.setPointerCapture?.(event.pointerId);
event.preventDefault();
event.stopPropagation();
};
const moveResize = (event: ReactPointerEvent<HTMLSpanElement>) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
const next = [...drag.startWidths];
next[drag.columnIndex] = clampColumnWidth(drag.startWidths[drag.columnIndex] + event.clientX - drag.startX);
setColumnWidths(next);
};
const stopResize = (event: ReactPointerEvent<HTMLSpanElement>) => {
if (dragRef.current?.pointerId !== event.pointerId) return;
dragRef.current = null;
if (event.currentTarget.hasPointerCapture?.(event.pointerId)) event.currentTarget.releasePointerCapture?.(event.pointerId);
};
const autoFit = (columnIndex: number) => {
const table = tableRef.current;
if (!enabled || !table) return;
const current = measuredColumnWidths(table, columnCount);
current[columnIndex] = fittedColumnWidth(table, columnIndex);
setColumnWidths(current);
};
const resizeByKeyboard = (columnIndex: number, delta: number) => {
const table = tableRef.current;
if (!enabled || !table) return;
const current = appliedWidths.length ? [...appliedWidths] : measuredColumnWidths(table, columnCount);
current[columnIndex] = clampColumnWidth(current[columnIndex] + delta);
setColumnWidths(current);
};
return (
<table ref={tableRef} className={`${className}${enabled ? ' is-column-resizable' : ''}${appliedWidths.length ? ' has-custom-column-widths' : ''}`} data-result-table-id={selection ? tableId : undefined} data-result-sheet-id={sourceSheetId} style={tableStyle}>
{(selection || enabled) && <caption className={selection ? 'result-table-selection' : 'result-column-instructions'}>
{selection && <label><input type="checkbox" checked={selection.selected.has(tableId)} onChange={() => selection.toggle(tableId)} /> Select table for Excel export</label>}
{enabled && <span className="result-column-instructions">Column widths can be changed by dragging a header separator or fitted by double-clicking it.</span>}
</caption>}
{appliedWidths.length > 0 && <colgroup>{appliedWidths.map((width, index) => <col key={index} style={columnWidthStyle(width)} />)}</colgroup>}
<thead><tr>{headers.map((header, index) => <th key={index}>{header}{enabled && index < columnCount - 1 && <span
className="result-column-resizer"
role="separator"
aria-label={`Resize column ${index + 1}`}
aria-orientation="vertical"
tabIndex={0}
onPointerDown={startResize(index)}
onPointerMove={moveResize}
onPointerUp={stopResize}
onPointerCancel={stopResize}
onDoubleClick={event => { event.preventDefault(); event.stopPropagation(); autoFit(index); }}
onKeyDown={event => {
if (event.key === 'Enter') { event.preventDefault(); autoFit(index); }
if (event.key === 'ArrowLeft') { event.preventDefault(); resizeByKeyboard(index, -16); }
if (event.key === 'ArrowRight') { event.preventDefault(); resizeByKeyboard(index, 16); }
}}
/>}</th>)}</tr></thead>
<tbody>{children}</tbody>
</table>
);
}
export { fittedColumnWidth, measuredColumnWidths };