From 5bf3ba08615b5569e947af751af08de6ea52893b Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:45:46 -0500 Subject: [PATCH 01/20] feat(forms): add resizable columns to subviews --- .../js_src/lib/components/Atoms/DataEntry.tsx | 2 +- .../lib/components/FormCells/FormTable.tsx | 325 ++++++++++++++++-- 2 files changed, 303 insertions(+), 24 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Atoms/DataEntry.tsx b/specifyweb/frontend/js_src/lib/components/Atoms/DataEntry.tsx index 1b8db504a00..ba106ff1dd1 100644 --- a/specifyweb/frontend/js_src/lib/components/Atoms/DataEntry.tsx +++ b/specifyweb/frontend/js_src/lib/components/Atoms/DataEntry.tsx @@ -138,7 +138,7 @@ export const DataEntry = { SubFormHeader: wrap( 'DataEntry.SubFormHeader', 'legend', - 'gap-2 flex font-bold border-b border-gray-500 pt-3 pb-1 items-center', + 'gap-2 flex font-bold border-b border-gray-500 pt-3 pb-0 items-center', ({ children, ...props }) => ({ // A hack for Safari. See https://github.com/specify/specify7/issues/1535 children:
{children}
, diff --git a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx index baf13a48768..d374d754e36 100644 --- a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx @@ -9,7 +9,7 @@ import { f } from '../../utils/functools'; import type { IR, RA } from '../../utils/types'; import { sortFunction } from '../../utils/utils'; import { Button } from '../Atoms/Button'; -import { columnDefinitionsToCss, DataEntry } from '../Atoms/DataEntry'; +import { DataEntry } from '../Atoms/DataEntry'; import { icons } from '../Atoms/Icons'; import { Link } from '../Atoms/Link'; import { useAttachment } from '../Attachments/Plugin'; @@ -59,7 +59,84 @@ const cellToLabel = ( }); const cellClassName = - 'sticky top-0 bg-[color:var(--form-foreground)] z-10 h-full -mx-1 pl-1 pt-1'; + 'sticky top-0 bg-[color:var(--form-foreground)] z-10 h-full -mx-1 px-1 py-1 border-b border-gray-500'; + +const minSubviewColumnWidth = 80; +const maxSubviewColumnWidth = 600; + +function measureSubviewText( + text: string, + font: string, + context: CanvasRenderingContext2D +): number { + context.font = font; + return Math.ceil( + Math.max( + 0, + ...text.split(/\r?\n/u).map((line) => context.measureText(line).width) + ) + ); +} + +function measureSubviewCell( + cell: HTMLElement, + context: CanvasRenderingContext2D +): number { + const controls = Array.from( + cell.querySelectorAll< + HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement + >('input, textarea, select') + ); + if (controls.length === 0) + return measureSubviewText( + cell.textContent ?? '', + getComputedStyle(cell).font, + context + ); + + return controls.reduce((total, control) => { + const style = getComputedStyle(control); + const horizontalPadding = + Number.parseFloat(style.paddingLeft) + + Number.parseFloat(style.paddingRight) + + Number.parseFloat(style.borderLeftWidth) + + Number.parseFloat(style.borderRightWidth); + if (control instanceof HTMLInputElement && control.type === 'checkbox') + return Math.max(total, 48); + if (control instanceof HTMLInputElement && control.type === 'date') + return Math.max(total, 136); + const value = + control instanceof HTMLSelectElement + ? (control.selectedOptions[0]?.textContent ?? '') + : control.value || control.getAttribute('placeholder') || ''; + const width = + measureSubviewText(value, style.font, context) + + horizontalPadding + + (control instanceof HTMLSelectElement + ? 28 + : control instanceof HTMLInputElement && control.type === 'number' + ? 30 + : 8); + return Math.max(total, width); + }, minSubviewColumnWidth); +} + +function fitSubviewColumnWidths( + widths: Array, + availableWidth: number +): Array { + const totalWidth = widths.reduce((total, width) => total + width, 0); + const minimumTotal = widths.length * minSubviewColumnWidth; + if (totalWidth <= availableWidth || totalWidth === minimumTotal) + return widths; + const scale = Math.max( + 0, + (availableWidth - minimumTotal) / (totalWidth - minimumTotal) + ); + return widths.map( + (width) => minSubviewColumnWidth + (width - minSubviewColumnWidth) * scale + ); +} // REFACTOR: split this component into smaller /** @@ -233,17 +310,158 @@ export function FormTable({ 'flexibleColumnWidth' ); - const [flexibleSubGridColumnWidth] = userPreferences.use( - 'form', - 'definition', - 'flexibleSubGridColumnWidth' - ); - const displayDeleteButton = mode !== 'view' && typeof handleDelete === 'function'; const displayViewButton = !isDependent; const scrollerRef = React.useRef(null); + const [tableWidth, setTableWidth] = React.useState(0); + const [contentColumnWidths, setContentColumnWidths] = React.useState< + Array + >([]); + const [tableChromeWidth, setTableChromeWidth] = React.useState(0); + const [columnWidths, setColumnWidths] = React.useState< + Record + >({}); + React.useEffect(() => { + const tableElement = scrollerRef.current; + if (tableElement === null) return; + const observer = new ResizeObserver(([entry]) => { + setTableWidth(Math.floor(entry.contentRect.width)); + }); + observer.observe(tableElement); + return (): void => observer.disconnect(); + }, [collapsedViewDefinition]); + React.useEffect(() => { + if (collapsedViewDefinition === undefined) return; + const frame = requestAnimationFrame(() => { + const tableElement = scrollerRef.current; + if (tableElement === null) return; + const context = document.createElement('canvas').getContext('2d'); + if (context === null) return; + const measuredWidths = collapsedViewDefinition.rows[0].map( + (_, columnIndex) => { + const header = tableElement.querySelector( + `[data-subview-header-col="${columnIndex}"]` + ); + const cells = Array.from( + tableElement.querySelectorAll( + `[data-subview-cell-col="${columnIndex}"]` + ) + ); + return Math.min( + maxSubviewColumnWidth, + Math.max( + minSubviewColumnWidth, + header === null + ? 0 + : measureSubviewText( + header.textContent ?? '', + getComputedStyle(header).font, + context + ) + 32, + ...cells.map((cell) => measureSubviewCell(cell, context)) + ) + ); + } + ); + const headers = tableElement.querySelectorAll( + '[role="columnheader"]' + ); + const buttonColumnsWidth = + (headers[0]?.getBoundingClientRect().width ?? 40) + + (headers[headers.length - 1]?.getBoundingClientRect().width ?? 40); + const style = getComputedStyle(tableElement); + const horizontalPadding = + Number.parseFloat(style.paddingLeft) + + Number.parseFloat(style.paddingRight); + const gap = Number.parseFloat(style.columnGap) || 0; + setTableChromeWidth( + buttonColumnsWidth + + horizontalPadding + + gap * (measuredWidths.length + 2) + ); + setContentColumnWidths(measuredWidths); + }); + return (): void => cancelAnimationFrame(frame); + }, [collapsedViewDefinition, resources]); + const resizeColumn = React.useCallback( + (columnIndex: number, event: React.MouseEvent): void => { + event.preventDefault(); + event.stopPropagation(); + const tableElement = scrollerRef.current; + if (tableElement === null) return; + const header = tableElement.querySelector( + `[data-subview-header-col="${columnIndex}"]` + ); + const initialWidth = + columnWidths[columnIndex] ?? header?.getBoundingClientRect().width ?? 0; + const startX = event.clientX; + let latestX = startX; + let frame: number | undefined; + const updateWidth = (clientX: number): void => + setColumnWidths((widths) => ({ + ...widths, + [columnIndex]: Math.max( + 60, + Math.min( + maxSubviewColumnWidth, + Math.ceil(initialWidth + clientX - startX) + ) + ), + })); + const handleMove = (moveEvent: MouseEvent): void => { + latestX = moveEvent.clientX; + if (frame !== undefined) return; + frame = requestAnimationFrame(() => { + frame = undefined; + updateWidth(latestX); + }); + }; + const handleUp = (): void => { + if (frame !== undefined) cancelAnimationFrame(frame); + updateWidth(latestX); + globalThis.removeEventListener('mousemove', handleMove); + globalThis.removeEventListener('mouseup', handleUp); + }; + globalThis.addEventListener('mousemove', handleMove); + globalThis.addEventListener('mouseup', handleUp); + }, + [columnWidths] + ); + const gridTemplateColumns = React.useMemo(() => { + const autoColumns = contentColumnWidths + .map((width, index) => ({ width, index })) + .filter(({ index }) => columnWidths[index] === undefined); + const fixedWidth = Object.values(columnWidths).reduce( + (total, width) => total + width, + 0 + ); + const widths = fitSubviewColumnWidths( + autoColumns.map(({ width }) => width), + tableWidth - tableChromeWidth - fixedWidth + ); + let autoIndex = 0; + return [ + 'min-content', + ...(collapsedViewDefinition?.rows[0] ?? []).map((_, index) => { + if (columnWidths[index] !== undefined) + return `${columnWidths[index]}px`; + const minimumWidth = widths[autoIndex] ?? minSubviewColumnWidth; + const flex = autoColumns[autoIndex]?.width ?? minSubviewColumnWidth; + autoIndex += 1; + return `minmax(${Math.floor(minimumWidth)}px, ${flex}fr)`; + }), + autoColumns.length === 0 ? 'minmax(0, 1fr)' : '0px', + 'min-content', + ].join(' '); + }, [ + collapsedViewDefinition?.rows, + columnWidths, + contentColumnWidths, + tableChromeWidth, + tableWidth, + ]); const { isFetching, handleScroll } = useInfiniteScroll( handleFetchMore, scrollerRef @@ -296,16 +514,14 @@ export function FormTable({ onScroll={handleScroll} > ({ {commonText.expand()} {collapsedViewDefinition.rows[0].map((cell, index) => { + const columnIndex = index; const { text, title } = cellToLabel( relationship.relatedTable, cell @@ -334,9 +551,10 @@ export function FormTable({ return ( ({ > {isSortable && typeof fieldName === 'string' ? ( setSortConfig({ sortField: fieldName, @@ -351,18 +570,52 @@ export function FormTable({ }) } > - {text} + + {text} + ) : ( - text + + {text} + )} +
+ resizeColumn(columnIndex, event) + } + /> ); })} +