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/21] 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)
+ }
+ />
);
})}
+
{commonText.actions()}
@@ -373,7 +626,11 @@ export function FormTable
({
{isExpanded[resource.cid] === true ? (
<>
-
+
({
({
}
>
{collapsedViewDefinition.isAttachmentPlugin ? (
-
+
) : (
collapsedViewDefinition.rows[0].map(
(
{
- colSpan,
align,
verticalAlign,
visible,
@@ -461,8 +727,10 @@ export function FormTable
({
) => (
({
>
)}
-
+ {isExpanded[resource.cid] !== true && (
+
+ )}
+
{displayViewButton &&
isExpanded[resource.cid] === true &&
!resource.isNew() ? (
From 62f393c2000154da6e3668a35ad1a434d5c352a8 Mon Sep 17 00:00:00 2001
From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com>
Date: Sat, 3 Oct 2026 06:10:31 -0500
Subject: [PATCH 02/21] fix(forms): simplify subview logic
---
.../lib/components/FormCells/FormTable.tsx | 37 ++++++++-----------
1 file changed, 15 insertions(+), 22 deletions(-)
diff --git a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
index d374d754e36..2639ffd3c61 100644
--- a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
+++ b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
@@ -548,6 +548,19 @@ export function FormTable
({
const fieldName = isSortable
? cell.fieldNames?.join(backboneFieldSeparator)
: undefined;
+ const label = (
+
+ {text}
+
+ );
return (
({
})
}
>
-
- {text}
-
+ {label}
) : (
-
- {text}
-
+ label
)}
Date: Sat, 3 Oct 2026 06:19:54 -0500
Subject: [PATCH 03/21] fix(forms): stop losing subview table header
---
.../js_src/lib/components/FormCells/FormTable.tsx | 12 +++++++-----
1 file changed, 7 insertions(+), 5 deletions(-)
diff --git a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
index 2639ffd3c61..b795c702ebc 100644
--- a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
+++ b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
@@ -315,6 +315,7 @@ export function FormTable
({
const displayViewButton = !isDependent;
const scrollerRef = React.useRef(null);
+ const scrollContainerRef = React.useRef(null);
const [tableWidth, setTableWidth] = React.useState(0);
const [contentColumnWidths, setContentColumnWidths] = React.useState<
Array
@@ -464,7 +465,7 @@ export function FormTable({
]);
const { isFetching, handleScroll } = useInfiniteScroll(
handleFetchMore,
- scrollerRef
+ scrollContainerRef
);
const [maxHeight] = userPreferences.use('form', 'formTable', 'maxHeight');
@@ -508,13 +509,15 @@ export function FormTable({
isCollapsed
? 'hidden'
: showSubviewBorders
- ? 'overflow-x-auto border border-gray-500 border-t-0 rounded-b pl-1 pr-1 pb-1'
- : 'overflow-x-auto pl-1 pr-1 pb-1'
+ ? 'overflow-auto border border-gray-500 border-t-0 rounded-b pl-1 pr-1 pb-1'
+ : 'overflow-auto pl-1 pr-1 pb-1'
}
+ ref={scrollContainerRef}
+ style={{ maxHeight: `${maxHeight}px` }}
onScroll={handleScroll}
>
({
style={{
gridTemplateColumns,
width: '100%',
- maxHeight: `${maxHeight}px`,
}}
viewDefinition={collapsedViewDefinition}
>
From b612355ad58c6dcc2a2cceb73111865b7e35b3f6 Mon Sep 17 00:00:00 2001
From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com>
Date: Sat, 3 Oct 2026 06:33:39 -0500
Subject: [PATCH 04/21] fix(forms): stabilize subview width measurement
---
.../lib/components/FormCells/FormTable.tsx | 27 ++++++++++---------
1 file changed, 15 insertions(+), 12 deletions(-)
diff --git a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
index b795c702ebc..7026ce49b00 100644
--- a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
+++ b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
@@ -188,17 +188,20 @@ export function FormTable({
}
);
- const resources =
- sortConfig === undefined
- ? // Note, resources might be sorted by the back-end
- unsortedResources
- : Array.from(unsortedResources).sort(
- sortFunction(
- // FEATURE: handle related fields
- (resource) => resource.get(sortConfig.sortField),
- !sortConfig.ascending
- )
- );
+ const resources = React.useMemo(
+ () =>
+ sortConfig === undefined
+ ? // Note, resources might be sorted by the back-end
+ unsortedResources
+ : Array.from(unsortedResources).sort(
+ sortFunction(
+ // FEATURE: handle related fields
+ (resource) => resource.get(sortConfig.sortField),
+ !sortConfig.ascending
+ )
+ ),
+ [sortConfig, unsortedResources]
+ );
// When added a new resource, focus that row
const addedResource = React.useRef | undefined>(
@@ -332,7 +335,7 @@ export function FormTable({
});
observer.observe(tableElement);
return (): void => observer.disconnect();
- }, [collapsedViewDefinition]);
+ }, [collapsedViewDefinition, resources.length > 0]);
React.useEffect(() => {
if (collapsedViewDefinition === undefined) return;
const frame = requestAnimationFrame(() => {
From ae69023572b70a982dc58f6a6488b2ff837aabef Mon Sep 17 00:00:00 2001
From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com>
Date: Sat, 3 Oct 2026 06:35:11 -0500
Subject: [PATCH 05/21] fix(forms): preserve subview column spans
---
.../lib/components/FormCells/FormTable.tsx | 34 ++++++++++++++-----
1 file changed, 26 insertions(+), 8 deletions(-)
diff --git a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
index 7026ce49b00..4f46c6c4d36 100644
--- a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
+++ b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
@@ -446,23 +446,40 @@ export function FormTable({
tableWidth - tableChromeWidth - fixedWidth
);
let autoIndex = 0;
+ const cells = collapsedViewDefinition?.rows[0] ?? [];
+ const cellColumns = cells.reduce((total, cell) => total + cell.colSpan, 0);
return [
'min-content',
- ...(collapsedViewDefinition?.rows[0] ?? []).map((_, index) => {
+ ...cells.flatMap((cell, index) => {
+ const span = cell.colSpan;
if (columnWidths[index] !== undefined)
- return `${columnWidths[index]}px`;
+ return Array.from(
+ { length: span },
+ () => `${columnWidths[index] / span}px`
+ );
const minimumWidth = widths[autoIndex] ?? minSubviewColumnWidth;
const flex = autoColumns[autoIndex]?.width ?? minSubviewColumnWidth;
autoIndex += 1;
- return `minmax(${Math.floor(minimumWidth)}px, ${flex}fr)`;
+ return Array.from(
+ { length: span },
+ () => `minmax(${minimumWidth / span}px, ${flex / span}fr)`
+ );
}),
+ ...(collapsedViewDefinition?.columns.slice(cellColumns) ?? []).map(
+ (width) =>
+ typeof width === 'number'
+ ? `${width}${flexibleColumnWidth ? 'fr' : 'px'}`
+ : 'minmax(0, 1fr)'
+ ),
autoColumns.length === 0 ? 'minmax(0, 1fr)' : '0px',
'min-content',
].join(' ');
}, [
+ collapsedViewDefinition?.columns,
collapsedViewDefinition?.rows,
columnWidths,
contentColumnWidths,
+ flexibleColumnWidth,
tableChromeWidth,
tableWidth,
]);
@@ -570,7 +587,7 @@ export function FormTable({
({
({
className="flex gap-8"
role="cell"
style={{
- gridColumn: `span ${collapsedViewDefinition.rows[0].length} / span ${collapsedViewDefinition.rows[0].length}`,
+ gridColumn: `span ${collapsedViewDefinition.columns.length} / span ${collapsedViewDefinition.columns.length}`,
}}
>
@@ -715,6 +732,7 @@ export function FormTable({
collapsedViewDefinition.rows[0].map(
(
{
+ colSpan,
align,
verticalAlign,
visible,
@@ -726,7 +744,7 @@ export function FormTable({
Date: Sat, 3 Oct 2026 06:35:17 -0500
Subject: [PATCH 06/21] feat(forms): support accessible subview column resizing
---
.../lib/components/FormCells/FormTable.tsx | 57 ++++++++++++++++---
1 file changed, 49 insertions(+), 8 deletions(-)
diff --git a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
index 4f46c6c4d36..1c60e76864b 100644
--- a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
+++ b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
@@ -390,7 +390,7 @@ export function FormTable({
return (): void => cancelAnimationFrame(frame);
}, [collapsedViewDefinition, resources]);
const resizeColumn = React.useCallback(
- (columnIndex: number, event: React.MouseEvent): void => {
+ (columnIndex: number, event: React.PointerEvent): void => {
event.preventDefault();
event.stopPropagation();
const tableElement = scrollerRef.current;
@@ -401,6 +401,7 @@ export function FormTable({
const initialWidth =
columnWidths[columnIndex] ?? header?.getBoundingClientRect().width ?? 0;
const startX = event.clientX;
+ const pointerId = event.pointerId;
let latestX = startX;
let frame: number | undefined;
const updateWidth = (clientX: number): void =>
@@ -414,7 +415,8 @@ export function FormTable({
)
),
}));
- const handleMove = (moveEvent: MouseEvent): void => {
+ const handleMove = (moveEvent: PointerEvent): void => {
+ if (moveEvent.pointerId !== pointerId) return;
latestX = moveEvent.clientX;
if (frame !== undefined) return;
frame = requestAnimationFrame(() => {
@@ -422,14 +424,17 @@ export function FormTable({
updateWidth(latestX);
});
};
- const handleUp = (): void => {
+ const handleUp = (upEvent: PointerEvent): void => {
+ if (upEvent.pointerId !== pointerId) return;
if (frame !== undefined) cancelAnimationFrame(frame);
updateWidth(latestX);
- globalThis.removeEventListener('mousemove', handleMove);
- globalThis.removeEventListener('mouseup', handleUp);
+ globalThis.removeEventListener('pointermove', handleMove);
+ globalThis.removeEventListener('pointerup', handleUp);
+ globalThis.removeEventListener('pointercancel', handleUp);
};
- globalThis.addEventListener('mousemove', handleMove);
- globalThis.addEventListener('mouseup', handleUp);
+ globalThis.addEventListener('pointermove', handleMove);
+ globalThis.addEventListener('pointerup', handleUp);
+ globalThis.addEventListener('pointercancel', handleUp);
},
[columnWidths]
);
@@ -616,10 +621,46 @@ export function FormTable({
)}
+ tabIndex={0}
+ onKeyDown={(event): void => {
+ if (
+ event.key !== 'ArrowLeft' &&
+ event.key !== 'ArrowRight'
+ )
+ return;
+ event.preventDefault();
+ const header =
+ scrollerRef.current?.querySelector
(
+ `[data-subview-header-col="${columnIndex}"]`
+ );
+ const currentWidth =
+ columnWidths[columnIndex] ??
+ header?.getBoundingClientRect().width ??
+ minSubviewColumnWidth;
+ setColumnWidths((widths) => ({
+ ...widths,
+ [columnIndex]: Math.max(
+ 60,
+ Math.min(
+ maxSubviewColumnWidth,
+ currentWidth +
+ (event.key === 'ArrowRight' ? 10 : -10)
+ )
+ ),
+ }));
+ }}
+ onPointerDown={(event): void =>
resizeColumn(columnIndex, event)
}
/>
From e5b99def0703839802e40c1b378c465ddea34d59 Mon Sep 17 00:00:00 2001
From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com>
Date: Sat, 3 Oct 2026 06:56:06 -0500
Subject: [PATCH 07/21] fix(forms): fit subview action column on load
---
.../frontend/js_src/lib/components/FormCells/FormTable.tsx | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
index 1c60e76864b..ec90319946f 100644
--- a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
+++ b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
@@ -372,9 +372,12 @@ export function FormTable({
const headers = tableElement.querySelectorAll(
'[role="columnheader"]'
);
+ const actionButton = tableElement.querySelector(
+ '[role="rowgroup"] [role="row"] [role="cell"]:last-child button'
+ );
const buttonColumnsWidth =
(headers[0]?.getBoundingClientRect().width ?? 40) +
- (headers[headers.length - 1]?.getBoundingClientRect().width ?? 40);
+ Math.max(40, actionButton?.getBoundingClientRect().width ?? 0);
const style = getComputedStyle(tableElement);
const horizontalPadding =
Number.parseFloat(style.paddingLeft) +
@@ -383,7 +386,7 @@ export function FormTable({
setTableChromeWidth(
buttonColumnsWidth +
horizontalPadding +
- gap * (measuredWidths.length + 2)
+ gap * (collapsedViewDefinition.columns.length + 2)
);
setContentColumnWidths(measuredWidths);
});
From a5437599c1dd142d5105f21a1ec800d32319a9f1 Mon Sep 17 00:00:00 2001
From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com>
Date: Sat, 3 Oct 2026 07:11:23 -0500
Subject: [PATCH 08/21] fix(forms): size subview headers across two lines
---
.../lib/components/FormCells/FormTable.tsx | 16 +++++++++++++++-
1 file changed, 15 insertions(+), 1 deletion(-)
diff --git a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
index ec90319946f..dd24994d462 100644
--- a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
+++ b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
@@ -78,6 +78,20 @@ function measureSubviewText(
);
}
+function measureSubviewHeader(
+ text: string,
+ font: string,
+ context: CanvasRenderingContext2D
+): number {
+ const words = text.trim().split(/\s+/u);
+ const middle = Math.ceil(words.length / 2);
+ return measureSubviewText(
+ `${words.slice(0, middle).join(' ')}\n${words.slice(middle).join(' ')}`,
+ font,
+ context
+ );
+}
+
function measureSubviewCell(
cell: HTMLElement,
context: CanvasRenderingContext2D
@@ -359,7 +373,7 @@ export function FormTable({
minSubviewColumnWidth,
header === null
? 0
- : measureSubviewText(
+ : measureSubviewHeader(
header.textContent ?? '',
getComputedStyle(header).font,
context
From 893d6aa2882efdbb81b0f689a5e3f35205a11dbe Mon Sep 17 00:00:00 2001
From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com>
Date: Sat, 3 Oct 2026 07:38:53 -0500
Subject: [PATCH 09/21] fix(forms): center wrapped subview headings
---
.../frontend/js_src/lib/components/FormCells/FormTable.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
index dd24994d462..78dac73f9dc 100644
--- a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
+++ b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
@@ -594,7 +594,7 @@ export function FormTable({
: undefined;
const label = (
Date: Sat, 3 Oct 2026 07:40:19 -0500
Subject: [PATCH 10/21] fix(forms): ignore sort state when sizing headers
---
.../js_src/lib/components/FormCells/FormTable.tsx | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
diff --git a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
index 78dac73f9dc..886c3f5fe7c 100644
--- a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
+++ b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
@@ -362,6 +362,9 @@ export function FormTable({
const header = tableElement.querySelector(
`[data-subview-header-col="${columnIndex}"]`
);
+ const headerLabel = header?.querySelector(
+ '[data-subview-header-label]'
+ );
const cells = Array.from(
tableElement.querySelectorAll(
`[data-subview-cell-col="${columnIndex}"]`
@@ -371,11 +374,11 @@ export function FormTable({
maxSubviewColumnWidth,
Math.max(
minSubviewColumnWidth,
- header === null
+ headerLabel === null || headerLabel === undefined
? 0
: measureSubviewHeader(
- header.textContent ?? '',
- getComputedStyle(header).font,
+ headerLabel.textContent ?? '',
+ getComputedStyle(headerLabel).font,
context
) + 32,
...cells.map((cell) => measureSubviewCell(cell, context))
@@ -600,6 +603,7 @@ export function FormTable({
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 2,
}}
+ data-subview-header-label
title={text}
>
{text}
From 906d88c0a39e198e0fe30aa9f5bfaef03a678e24 Mon Sep 17 00:00:00 2001
From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com>
Date: Sat, 3 Oct 2026 07:54:20 -0500
Subject: [PATCH 11/21] fix(forms): sorting measures active headers only
---
.../lib/components/FormCells/FormTable.tsx | 93 +++++++++++++++----
1 file changed, 76 insertions(+), 17 deletions(-)
diff --git a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
index 886c3f5fe7c..0b1980897cd 100644
--- a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
+++ b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
@@ -137,19 +137,21 @@ function measureSubviewCell(
function fitSubviewColumnWidths(
widths: Array,
- availableWidth: number
+ availableWidth: number,
+ minimumWidths: Array
): 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
+ const minimumTotal = minimumWidths.reduce((total, width) => total + width, 0);
+ const preferredWidths = widths.map((width, index) =>
+ Math.max(width, minimumWidths[index] ?? minSubviewColumnWidth)
);
+ const totalWidth = preferredWidths.reduce((total, width) => total + width, 0);
+ if (totalWidth <= availableWidth) return preferredWidths;
+ if (availableWidth <= minimumTotal) return minimumWidths;
+ const scale = (availableWidth - minimumTotal) / (totalWidth - minimumTotal);
+ return preferredWidths.map((width, index) => {
+ const minimum = minimumWidths[index] ?? minSubviewColumnWidth;
+ return minimum + (width - minimum) * scale;
+ });
}
// REFACTOR: split this component into smaller
@@ -337,6 +339,9 @@ export function FormTable({
const [contentColumnWidths, setContentColumnWidths] = React.useState<
Array
>([]);
+ const [headerColumnWidths, setHeaderColumnWidths] = React.useState<
+ Array
+ >([]);
const [tableChromeWidth, setTableChromeWidth] = React.useState(0);
const [columnWidths, setColumnWidths] = React.useState<
Record
@@ -408,7 +413,43 @@ export function FormTable({
setContentColumnWidths(measuredWidths);
});
return (): void => cancelAnimationFrame(frame);
- }, [collapsedViewDefinition, resources]);
+ }, [collapsedViewDefinition, unsortedResources]);
+ React.useEffect(() => {
+ 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 label = header?.querySelector(
+ '[data-subview-header-label]'
+ );
+ const icon = header?.querySelector(
+ '[data-subview-sort-indicator]'
+ );
+ return label === null || label === undefined
+ ? minSubviewColumnWidth
+ : Math.min(
+ maxSubviewColumnWidth,
+ measureSubviewHeader(
+ label.textContent ?? '',
+ getComputedStyle(label).font,
+ context
+ ) +
+ 32 +
+ (icon?.getBoundingClientRect().width ?? 0) +
+ (icon === null || icon === undefined ? 0 : 8)
+ );
+ }
+ );
+ setHeaderColumnWidths(measuredWidths);
+ });
+ return (): void => cancelAnimationFrame(frame);
+ }, [collapsedViewDefinition, sortConfig]);
const resizeColumn = React.useCallback(
(columnIndex: number, event: React.PointerEvent): void => {
event.preventDefault();
@@ -466,9 +507,23 @@ export function FormTable({
(total, width) => total + width,
0
);
+ const minimumWidths = autoColumns.map(({ index }) => {
+ const definition = collapsedViewDefinition?.rows[0]?.[index];
+ const fieldName =
+ definition !== undefined && 'fieldNames' in definition
+ ? definition.fieldNames?.join(backboneFieldSeparator)
+ : undefined;
+ return sortConfig?.sortField === fieldName
+ ? Math.max(
+ minSubviewColumnWidth,
+ headerColumnWidths[index] ?? minSubviewColumnWidth
+ )
+ : minSubviewColumnWidth;
+ });
const widths = fitSubviewColumnWidths(
autoColumns.map(({ width }) => width),
- tableWidth - tableChromeWidth - fixedWidth
+ tableWidth - tableChromeWidth - fixedWidth,
+ minimumWidths
);
let autoIndex = 0;
const cells = collapsedViewDefinition?.rows[0] ?? [];
@@ -504,6 +559,8 @@ export function FormTable({
collapsedViewDefinition?.rows,
columnWidths,
contentColumnWidths,
+ headerColumnWidths,
+ sortConfig,
flexibleColumnWidth,
tableChromeWidth,
tableWidth,
@@ -632,10 +689,12 @@ export function FormTable({
}
>
{label}
-
+
+
+
) : (
label
From 237b24774feb7cc2ae973570a9d243da5c8222e7 Mon Sep 17 00:00:00 2001
From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com>
Date: Sat, 3 Oct 2026 08:03:53 -0500
Subject: [PATCH 12/21] refactor(forms): simplify sorted header sizing
---
.../lib/components/FormCells/FormTable.tsx | 83 ++++++++-----------
1 file changed, 33 insertions(+), 50 deletions(-)
diff --git a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
index 0b1980897cd..f11df468e1b 100644
--- a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
+++ b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
@@ -362,7 +362,7 @@ export function FormTable({
if (tableElement === null) return;
const context = document.createElement('canvas').getContext('2d');
if (context === null) return;
- const measuredWidths = collapsedViewDefinition.rows[0].map(
+ const measuredColumns = collapsedViewDefinition.rows[0].map(
(_, columnIndex) => {
const header = tableElement.querySelector(
`[data-subview-header-col="${columnIndex}"]`
@@ -375,22 +375,41 @@ export function FormTable({
`[data-subview-cell-col="${columnIndex}"]`
)
);
- return Math.min(
- maxSubviewColumnWidth,
- Math.max(
- minSubviewColumnWidth,
- headerLabel === null || headerLabel === undefined
- ? 0
- : measureSubviewHeader(
- headerLabel.textContent ?? '',
- getComputedStyle(headerLabel).font,
- context
- ) + 32,
- ...cells.map((cell) => measureSubviewCell(cell, context))
- )
+ const labelWidth =
+ headerLabel === null || headerLabel === undefined
+ ? 0
+ : measureSubviewHeader(
+ headerLabel.textContent ?? '',
+ getComputedStyle(headerLabel).font,
+ context
+ ) + 32;
+ const indicator = header?.querySelector(
+ '[data-subview-sort-indicator]'
);
+ const headerWidth =
+ labelWidth +
+ (indicator === undefined || indicator === null ? 0 : 28);
+ return {
+ headerWidth: Math.min(maxSubviewColumnWidth, headerWidth),
+ contentWidth: Math.min(
+ maxSubviewColumnWidth,
+ Math.max(
+ minSubviewColumnWidth,
+ labelWidth,
+ ...cells.map((cell) => measureSubviewCell(cell, context))
+ )
+ ),
+ };
}
);
+ const measuredWidths = measuredColumns.map(
+ ({ contentWidth }) => contentWidth
+ );
+ setHeaderColumnWidths(
+ measuredColumns.map(({ headerWidth }) =>
+ Math.max(minSubviewColumnWidth, headerWidth)
+ )
+ );
const headers = tableElement.querySelectorAll(
'[role="columnheader"]'
);
@@ -414,42 +433,6 @@ export function FormTable({
});
return (): void => cancelAnimationFrame(frame);
}, [collapsedViewDefinition, unsortedResources]);
- React.useEffect(() => {
- 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 label = header?.querySelector(
- '[data-subview-header-label]'
- );
- const icon = header?.querySelector(
- '[data-subview-sort-indicator]'
- );
- return label === null || label === undefined
- ? minSubviewColumnWidth
- : Math.min(
- maxSubviewColumnWidth,
- measureSubviewHeader(
- label.textContent ?? '',
- getComputedStyle(label).font,
- context
- ) +
- 32 +
- (icon?.getBoundingClientRect().width ?? 0) +
- (icon === null || icon === undefined ? 0 : 8)
- );
- }
- );
- setHeaderColumnWidths(measuredWidths);
- });
- return (): void => cancelAnimationFrame(frame);
- }, [collapsedViewDefinition, sortConfig]);
const resizeColumn = React.useCallback(
(columnIndex: number, event: React.PointerEvent): void => {
event.preventDefault();
From a28e87ae4d2a151c8af26248ccd3fa0d527a0558 Mon Sep 17 00:00:00 2001
From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com>
Date: Sat, 3 Oct 2026 08:20:28 -0500
Subject: [PATCH 13/21] fix(forms): center subview column headings
---
.../frontend/js_src/lib/components/FormCells/FormTable.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
index f11df468e1b..a92b52f20f6 100644
--- a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
+++ b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
@@ -652,7 +652,7 @@ export function FormTable({
return (
Date: Sat, 3 Oct 2026 08:30:21 -0500
Subject: [PATCH 14/21] fix(forms): restore subview paginator padding
---
specifyweb/frontend/js_src/lib/components/Atoms/DataEntry.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/specifyweb/frontend/js_src/lib/components/Atoms/DataEntry.tsx b/specifyweb/frontend/js_src/lib/components/Atoms/DataEntry.tsx
index ba106ff1dd1..1b8db504a00 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-0 items-center',
+ 'gap-2 flex font-bold border-b border-gray-500 pt-3 pb-1 items-center',
({ children, ...props }) => ({
// A hack for Safari. See https://github.com/specify/specify7/issues/1535
children: {children}
,
From 84084ddc4efd5bf5d89c553e8c61d8c34e243bbd Mon Sep 17 00:00:00 2001
From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com>
Date: Sat, 3 Oct 2026 08:53:11 -0500
Subject: [PATCH 15/21] fix(forms): keep subview actions visible when narrow
---
.../js_src/lib/components/FormCells/FormTable.tsx | 12 ++++++++----
1 file changed, 8 insertions(+), 4 deletions(-)
diff --git a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
index a92b52f20f6..7ce83d24350 100644
--- a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
+++ b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
@@ -146,7 +146,11 @@ function fitSubviewColumnWidths(
);
const totalWidth = preferredWidths.reduce((total, width) => total + width, 0);
if (totalWidth <= availableWidth) return preferredWidths;
- if (availableWidth <= minimumTotal) return minimumWidths;
+ if (availableWidth <= 0) return minimumWidths;
+ if (availableWidth <= minimumTotal)
+ return minimumWidths.map(
+ (width) => (width * availableWidth) / minimumTotal
+ );
const scale = (availableWidth - minimumTotal) / (totalWidth - minimumTotal);
return preferredWidths.map((width, index) => {
const minimum = minimumWidths[index] ?? minSubviewColumnWidth;
@@ -347,12 +351,12 @@ export function FormTable({
Record
>({});
React.useEffect(() => {
- const tableElement = scrollerRef.current;
- if (tableElement === null) return;
+ const scrollContainer = scrollContainerRef.current;
+ if (scrollContainer === null) return;
const observer = new ResizeObserver(([entry]) => {
setTableWidth(Math.floor(entry.contentRect.width));
});
- observer.observe(tableElement);
+ observer.observe(scrollContainer);
return (): void => observer.disconnect();
}, [collapsedViewDefinition, resources.length > 0]);
React.useEffect(() => {
From f126f46488251595a335318b197fd405c9af1d34 Mon Sep 17 00:00:00 2001
From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com>
Date: Sat, 3 Oct 2026 09:28:17 -0500
Subject: [PATCH 16/21] fix(forms): increase grab area for subview resizing
---
.../frontend/js_src/lib/components/FormCells/FormTable.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
index 7ce83d24350..8203e4a9cce 100644
--- a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
+++ b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
@@ -696,9 +696,9 @@ export function FormTable({
contentColumnWidths[columnIndex] ??
minSubviewColumnWidth
)}
- className="absolute inset-y-0 z-20 w-2 cursor-col-resize touch-none before:absolute before:inset-y-0 before:right-1 before:w-px before:bg-gray-500 before:content-['']"
+ className="absolute inset-y-0 z-20 w-4 cursor-col-resize touch-none before:absolute before:inset-y-0 before:right-2 before:w-px before:bg-gray-500 before:content-['']"
role="separator"
- style={{ right: -4 }}
+ style={{ right: -8 }}
tabIndex={0}
onKeyDown={(event): void => {
if (
From f311505ab139603c1eb9972aa00472b14537cac7 Mon Sep 17 00:00:00 2001
From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com>
Date: Sat, 3 Oct 2026 18:28:52 -0500
Subject: [PATCH 17/21] fix(forms): handle custom columnDef=table
---
.../lib/components/FormCells/FormTable.tsx | 79 +++++++++++++------
1 file changed, 56 insertions(+), 23 deletions(-)
diff --git a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
index 8203e4a9cce..d97c08a0fa9 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 { DataEntry } from '../Atoms/DataEntry';
+import { columnDefinitionsToCss, DataEntry } from '../Atoms/DataEntry';
import { icons } from '../Atoms/Icons';
import { Link } from '../Atoms/Link';
import { useAttachment } from '../Attachments/Plugin';
@@ -333,6 +333,12 @@ export function FormTable({
'flexibleColumnWidth'
);
+ const [flexibleSubGridColumnWidth] = userPreferences.use(
+ 'form',
+ 'definition',
+ 'flexibleSubGridColumnWidth'
+ );
+
const displayDeleteButton =
mode !== 'view' && typeof handleDelete === 'function';
const displayViewButton = !isDependent;
@@ -487,15 +493,46 @@ export function FormTable({
[columnWidths]
);
const gridTemplateColumns = React.useMemo(() => {
- const autoColumns = contentColumnWidths
- .map((width, index) => ({ width, index }))
- .filter(({ index }) => columnWidths[index] === undefined);
+ const cells = collapsedViewDefinition?.rows[0] ?? [];
+ const definitions = collapsedViewDefinition?.columns ?? [];
+ let trackIndex = 0;
+ const tracks = cells.flatMap((cell, cellIndex) => {
+ const span = cell.colSpan;
+ return Array.from({ length: span }, () => {
+ const index = trackIndex++;
+ const customWidth = definitions[index];
+ return {
+ cellIndex,
+ index,
+ width:
+ columnWidths[cellIndex] === undefined ? customWidth : undefined,
+ };
+ });
+ });
+ const autoColumns = tracks
+ .filter(
+ ({ width, cellIndex }) =>
+ typeof width !== 'number' && columnWidths[cellIndex] === undefined
+ )
+ .map(({ cellIndex, index }) => ({
+ cellIndex,
+ index,
+ width:
+ (contentColumnWidths[cellIndex] ?? minSubviewColumnWidth) /
+ (cells[cellIndex]?.colSpan ?? 1),
+ }));
const fixedWidth = Object.values(columnWidths).reduce(
(total, width) => total + width,
0
);
- const minimumWidths = autoColumns.map(({ index }) => {
- const definition = collapsedViewDefinition?.rows[0]?.[index];
+ const customFixedWidth = flexibleSubGridColumnWidth
+ ? 0
+ : tracks.reduce(
+ (total, { width }) => total + (typeof width === 'number' ? width : 0),
+ 0
+ );
+ const minimumWidths = autoColumns.map(({ cellIndex }) => {
+ const definition = cells[cellIndex];
const fieldName =
definition !== undefined && 'fieldNames' in definition
? definition.fieldNames?.join(backboneFieldSeparator)
@@ -503,39 +540,35 @@ export function FormTable({
return sortConfig?.sortField === fieldName
? Math.max(
minSubviewColumnWidth,
- headerColumnWidths[index] ?? minSubviewColumnWidth
+ (headerColumnWidths[cellIndex] ?? minSubviewColumnWidth) /
+ (definition?.colSpan ?? 1)
)
: minSubviewColumnWidth;
});
const widths = fitSubviewColumnWidths(
autoColumns.map(({ width }) => width),
- tableWidth - tableChromeWidth - fixedWidth,
+ tableWidth - tableChromeWidth - fixedWidth - customFixedWidth,
minimumWidths
);
- let autoIndex = 0;
- const cells = collapsedViewDefinition?.rows[0] ?? [];
const cellColumns = cells.reduce((total, cell) => total + cell.colSpan, 0);
+ let autoIndex = 0;
return [
'min-content',
- ...cells.flatMap((cell, index) => {
- const span = cell.colSpan;
- if (columnWidths[index] !== undefined)
- return Array.from(
- { length: span },
- () => `${columnWidths[index] / span}px`
- );
+ ...tracks.map(({ cellIndex, width }) => {
+ const cell = cells[cellIndex];
+ if (columnWidths[cellIndex] !== undefined)
+ return `${columnWidths[cellIndex] / (cell?.colSpan ?? 1)}px`;
+ if (typeof width === 'number')
+ return columnDefinitionsToCss([width], flexibleSubGridColumnWidth);
const minimumWidth = widths[autoIndex] ?? minSubviewColumnWidth;
const flex = autoColumns[autoIndex]?.width ?? minSubviewColumnWidth;
autoIndex += 1;
- return Array.from(
- { length: span },
- () => `minmax(${minimumWidth / span}px, ${flex / span}fr)`
- );
+ return `minmax(${minimumWidth}px, ${flex}fr)`;
}),
...(collapsedViewDefinition?.columns.slice(cellColumns) ?? []).map(
(width) =>
typeof width === 'number'
- ? `${width}${flexibleColumnWidth ? 'fr' : 'px'}`
+ ? columnDefinitionsToCss([width], flexibleSubGridColumnWidth)
: 'minmax(0, 1fr)'
),
autoColumns.length === 0 ? 'minmax(0, 1fr)' : '0px',
@@ -548,7 +581,7 @@ export function FormTable({
contentColumnWidths,
headerColumnWidths,
sortConfig,
- flexibleColumnWidth,
+ flexibleSubGridColumnWidth,
tableChromeWidth,
tableWidth,
]);
From 0d62e1566e8b4d3a64ef9b590b0d7124ac833932 Mon Sep 17 00:00:00 2001
From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com>
Date: Sun, 4 Oct 2026 19:38:15 -0500
Subject: [PATCH 18/21] fix(forms): check collapse status
---
.../frontend/js_src/lib/components/FormCells/FormTable.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
index d97c08a0fa9..79eb0185a84 100644
--- a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
+++ b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
@@ -364,7 +364,7 @@ export function FormTable({
});
observer.observe(scrollContainer);
return (): void => observer.disconnect();
- }, [collapsedViewDefinition, resources.length > 0]);
+ }, [collapsedViewDefinition, isCollapsed, resources.length > 0]);
React.useEffect(() => {
if (collapsedViewDefinition === undefined) return;
const frame = requestAnimationFrame(() => {
From a175ba1a31efc94bc7fa033b265582a7e7bc5a09 Mon Sep 17 00:00:00 2001
From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com>
Date: Sun, 4 Oct 2026 19:42:23 -0500
Subject: [PATCH 19/21] fix(forms): handle collapsed in measurement
---
.../frontend/js_src/lib/components/FormCells/FormTable.tsx | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
index 79eb0185a84..116e78e2980 100644
--- a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
+++ b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
@@ -442,7 +442,7 @@ export function FormTable({
setContentColumnWidths(measuredWidths);
});
return (): void => cancelAnimationFrame(frame);
- }, [collapsedViewDefinition, unsortedResources]);
+ }, [collapsedViewDefinition, isCollapsed, unsortedResources]);
const resizeColumn = React.useCallback(
(columnIndex: number, event: React.PointerEvent): void => {
event.preventDefault();
@@ -771,6 +771,7 @@ export function FormTable({
aria-hidden="true"
className={cellClassName}
role="columnheader"
+ style={{ gridColumn: '-3 / -2' }}
/>
{commonText.actions()}
@@ -916,6 +917,7 @@ export function FormTable
({
aria-hidden="true"
className="border-b border-gray-200"
role="cell"
+ style={{ gridColumn: '-3 / -2' }}
/>
)}
Date: Mon, 5 Oct 2026 19:48:56 -0500
Subject: [PATCH 20/21] fix(forms): cleanup
---
.../lib/components/FormCells/FormTable.tsx | 36 ++++++++++++++-----
1 file changed, 27 insertions(+), 9 deletions(-)
diff --git a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
index 116e78e2980..3b00190ad93 100644
--- a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
+++ b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
@@ -146,7 +146,7 @@ function fitSubviewColumnWidths(
);
const totalWidth = preferredWidths.reduce((total, width) => total + width, 0);
if (totalWidth <= availableWidth) return preferredWidths;
- if (availableWidth <= 0) return minimumWidths;
+ if (minimumTotal === 0) return minimumWidths;
if (availableWidth <= minimumTotal)
return minimumWidths.map(
(width) => (width * availableWidth) / minimumTotal
@@ -443,10 +443,14 @@ export function FormTable
({
});
return (): void => cancelAnimationFrame(frame);
}, [collapsedViewDefinition, isCollapsed, unsortedResources]);
+ const resizeCleanupRef = React.useRef<(() => void) | undefined>(undefined);
+ React.useEffect(() => (): void => resizeCleanupRef.current?.(), []);
const resizeColumn = React.useCallback(
(columnIndex: number, event: React.PointerEvent): void => {
event.preventDefault();
event.stopPropagation();
+ resizeCleanupRef.current?.();
+ resizeCleanupRef.current = undefined;
const tableElement = scrollerRef.current;
if (tableElement === null) return;
const header = tableElement.querySelector(
@@ -480,15 +484,21 @@ export function FormTable({
};
const handleUp = (upEvent: PointerEvent): void => {
if (upEvent.pointerId !== pointerId) return;
- if (frame !== undefined) cancelAnimationFrame(frame);
+ cleanup();
updateWidth(latestX);
+ };
+ const cleanup = (): void => {
+ if (frame !== undefined) cancelAnimationFrame(frame);
globalThis.removeEventListener('pointermove', handleMove);
globalThis.removeEventListener('pointerup', handleUp);
globalThis.removeEventListener('pointercancel', handleUp);
+ if (resizeCleanupRef.current === cleanup)
+ resizeCleanupRef.current = undefined;
};
globalThis.addEventListener('pointermove', handleMove);
globalThis.addEventListener('pointerup', handleUp);
globalThis.addEventListener('pointercancel', handleUp);
+ resizeCleanupRef.current = cleanup;
},
[columnWidths]
);
@@ -525,12 +535,17 @@ export function FormTable({
(total, width) => total + width,
0
);
- const customFixedWidth = flexibleSubGridColumnWidth
- ? 0
- : tracks.reduce(
- (total, { width }) => total + (typeof width === 'number' ? width : 0),
- 0
- );
+ const customFixedWidth = tracks.reduce(
+ (total, { cellIndex, width }) =>
+ total +
+ (typeof width !== 'number'
+ ? 0
+ : flexibleSubGridColumnWidth
+ ? (contentColumnWidths[cellIndex] ?? minSubviewColumnWidth) /
+ (cells[cellIndex]?.colSpan ?? 1)
+ : width),
+ 0
+ );
const minimumWidths = autoColumns.map(({ cellIndex }) => {
const definition = cells[cellIndex];
const fieldName =
@@ -547,7 +562,10 @@ export function FormTable({
});
const widths = fitSubviewColumnWidths(
autoColumns.map(({ width }) => width),
- tableWidth - tableChromeWidth - fixedWidth - customFixedWidth,
+ Math.max(
+ 0,
+ tableWidth - tableChromeWidth - fixedWidth - customFixedWidth
+ ),
minimumWidths
);
const cellColumns = cells.reduce((total, cell) => total + cell.colSpan, 0);
From 512885606c54cabb2a7c86d17ee6009cf4eb6cf5 Mon Sep 17 00:00:00 2001
From: Caroline Denis
Date: Tue, 13 Oct 2026 10:38:55 +0200
Subject: [PATCH 21/21] Refactor: Add a form table column util file
---
.../lib/components/FormCells/FormTable.tsx | 421 +---------------
.../FormCells/useFormTableColumns.tsx | 462 ++++++++++++++++++
2 files changed, 482 insertions(+), 401 deletions(-)
create mode 100644 specifyweb/frontend/js_src/lib/components/FormCells/useFormTableColumns.tsx
diff --git a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx
index 3b00190ad93..ed0f9728cf5 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';
@@ -41,6 +41,10 @@ import { AttachmentPluginSkeleton } from '../SkeletonLoaders/AttachmentPlugin';
import { relationshipIsToMany } from '../WbPlanView/mappingHelpers';
import { COJODialog } from './COJODialog';
import { FormCell } from './index';
+import {
+ FormTableColumnResizeHandle,
+ useFormTableColumns,
+} from './useFormTableColumns';
const cellToLabel = (
table: SpecifyTable,
@@ -61,103 +65,6 @@ const cellToLabel = (
const cellClassName =
'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 measureSubviewHeader(
- text: string,
- font: string,
- context: CanvasRenderingContext2D
-): number {
- const words = text.trim().split(/\s+/u);
- const middle = Math.ceil(words.length / 2);
- return measureSubviewText(
- `${words.slice(0, middle).join(' ')}\n${words.slice(middle).join(' ')}`,
- font,
- context
- );
-}
-
-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,
- minimumWidths: Array
-): Array {
- const minimumTotal = minimumWidths.reduce((total, width) => total + width, 0);
- const preferredWidths = widths.map((width, index) =>
- Math.max(width, minimumWidths[index] ?? minSubviewColumnWidth)
- );
- const totalWidth = preferredWidths.reduce((total, width) => total + width, 0);
- if (totalWidth <= availableWidth) return preferredWidths;
- if (minimumTotal === 0) return minimumWidths;
- if (availableWidth <= minimumTotal)
- return minimumWidths.map(
- (width) => (width * availableWidth) / minimumTotal
- );
- const scale = (availableWidth - minimumTotal) / (totalWidth - minimumTotal);
- return preferredWidths.map((width, index) => {
- const minimum = minimumWidths[index] ?? minSubviewColumnWidth;
- return minimum + (width - minimum) * scale;
- });
-}
-
// REFACTOR: split this component into smaller
/**
* Show several records in "grid view"
@@ -343,266 +250,19 @@ export function FormTable({
mode !== 'view' && typeof handleDelete === 'function';
const displayViewButton = !isDependent;
- const scrollerRef = React.useRef(null);
- const scrollContainerRef = React.useRef(null);
- const [tableWidth, setTableWidth] = React.useState(0);
- const [contentColumnWidths, setContentColumnWidths] = React.useState<
- Array
- >([]);
- const [headerColumnWidths, setHeaderColumnWidths] = React.useState<
- Array
- >([]);
- const [tableChromeWidth, setTableChromeWidth] = React.useState(0);
- const [columnWidths, setColumnWidths] = React.useState<
- Record
- >({});
- React.useEffect(() => {
- const scrollContainer = scrollContainerRef.current;
- if (scrollContainer === null) return;
- const observer = new ResizeObserver(([entry]) => {
- setTableWidth(Math.floor(entry.contentRect.width));
- });
- observer.observe(scrollContainer);
- return (): void => observer.disconnect();
- }, [collapsedViewDefinition, isCollapsed, resources.length > 0]);
- 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 measuredColumns = collapsedViewDefinition.rows[0].map(
- (_, columnIndex) => {
- const header = tableElement.querySelector(
- `[data-subview-header-col="${columnIndex}"]`
- );
- const headerLabel = header?.querySelector(
- '[data-subview-header-label]'
- );
- const cells = Array.from(
- tableElement.querySelectorAll(
- `[data-subview-cell-col="${columnIndex}"]`
- )
- );
- const labelWidth =
- headerLabel === null || headerLabel === undefined
- ? 0
- : measureSubviewHeader(
- headerLabel.textContent ?? '',
- getComputedStyle(headerLabel).font,
- context
- ) + 32;
- const indicator = header?.querySelector(
- '[data-subview-sort-indicator]'
- );
- const headerWidth =
- labelWidth +
- (indicator === undefined || indicator === null ? 0 : 28);
- return {
- headerWidth: Math.min(maxSubviewColumnWidth, headerWidth),
- contentWidth: Math.min(
- maxSubviewColumnWidth,
- Math.max(
- minSubviewColumnWidth,
- labelWidth,
- ...cells.map((cell) => measureSubviewCell(cell, context))
- )
- ),
- };
- }
- );
- const measuredWidths = measuredColumns.map(
- ({ contentWidth }) => contentWidth
- );
- setHeaderColumnWidths(
- measuredColumns.map(({ headerWidth }) =>
- Math.max(minSubviewColumnWidth, headerWidth)
- )
- );
- const headers = tableElement.querySelectorAll(
- '[role="columnheader"]'
- );
- const actionButton = tableElement.querySelector(
- '[role="rowgroup"] [role="row"] [role="cell"]:last-child button'
- );
- const buttonColumnsWidth =
- (headers[0]?.getBoundingClientRect().width ?? 40) +
- Math.max(40, actionButton?.getBoundingClientRect().width ?? 0);
- 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 * (collapsedViewDefinition.columns.length + 2)
- );
- setContentColumnWidths(measuredWidths);
- });
- return (): void => cancelAnimationFrame(frame);
- }, [collapsedViewDefinition, isCollapsed, unsortedResources]);
- const resizeCleanupRef = React.useRef<(() => void) | undefined>(undefined);
- React.useEffect(() => (): void => resizeCleanupRef.current?.(), []);
- const resizeColumn = React.useCallback(
- (columnIndex: number, event: React.PointerEvent): void => {
- event.preventDefault();
- event.stopPropagation();
- resizeCleanupRef.current?.();
- resizeCleanupRef.current = undefined;
- 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;
- const pointerId = event.pointerId;
- 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: PointerEvent): void => {
- if (moveEvent.pointerId !== pointerId) return;
- latestX = moveEvent.clientX;
- if (frame !== undefined) return;
- frame = requestAnimationFrame(() => {
- frame = undefined;
- updateWidth(latestX);
- });
- };
- const handleUp = (upEvent: PointerEvent): void => {
- if (upEvent.pointerId !== pointerId) return;
- cleanup();
- updateWidth(latestX);
- };
- const cleanup = (): void => {
- if (frame !== undefined) cancelAnimationFrame(frame);
- globalThis.removeEventListener('pointermove', handleMove);
- globalThis.removeEventListener('pointerup', handleUp);
- globalThis.removeEventListener('pointercancel', handleUp);
- if (resizeCleanupRef.current === cleanup)
- resizeCleanupRef.current = undefined;
- };
- globalThis.addEventListener('pointermove', handleMove);
- globalThis.addEventListener('pointerup', handleUp);
- globalThis.addEventListener('pointercancel', handleUp);
- resizeCleanupRef.current = cleanup;
- },
- [columnWidths]
- );
- const gridTemplateColumns = React.useMemo(() => {
- const cells = collapsedViewDefinition?.rows[0] ?? [];
- const definitions = collapsedViewDefinition?.columns ?? [];
- let trackIndex = 0;
- const tracks = cells.flatMap((cell, cellIndex) => {
- const span = cell.colSpan;
- return Array.from({ length: span }, () => {
- const index = trackIndex++;
- const customWidth = definitions[index];
- return {
- cellIndex,
- index,
- width:
- columnWidths[cellIndex] === undefined ? customWidth : undefined,
- };
- });
- });
- const autoColumns = tracks
- .filter(
- ({ width, cellIndex }) =>
- typeof width !== 'number' && columnWidths[cellIndex] === undefined
- )
- .map(({ cellIndex, index }) => ({
- cellIndex,
- index,
- width:
- (contentColumnWidths[cellIndex] ?? minSubviewColumnWidth) /
- (cells[cellIndex]?.colSpan ?? 1),
- }));
- const fixedWidth = Object.values(columnWidths).reduce(
- (total, width) => total + width,
- 0
- );
- const customFixedWidth = tracks.reduce(
- (total, { cellIndex, width }) =>
- total +
- (typeof width !== 'number'
- ? 0
- : flexibleSubGridColumnWidth
- ? (contentColumnWidths[cellIndex] ?? minSubviewColumnWidth) /
- (cells[cellIndex]?.colSpan ?? 1)
- : width),
- 0
- );
- const minimumWidths = autoColumns.map(({ cellIndex }) => {
- const definition = cells[cellIndex];
- const fieldName =
- definition !== undefined && 'fieldNames' in definition
- ? definition.fieldNames?.join(backboneFieldSeparator)
- : undefined;
- return sortConfig?.sortField === fieldName
- ? Math.max(
- minSubviewColumnWidth,
- (headerColumnWidths[cellIndex] ?? minSubviewColumnWidth) /
- (definition?.colSpan ?? 1)
- )
- : minSubviewColumnWidth;
- });
- const widths = fitSubviewColumnWidths(
- autoColumns.map(({ width }) => width),
- Math.max(
- 0,
- tableWidth - tableChromeWidth - fixedWidth - customFixedWidth
- ),
- minimumWidths
- );
- const cellColumns = cells.reduce((total, cell) => total + cell.colSpan, 0);
- let autoIndex = 0;
- return [
- 'min-content',
- ...tracks.map(({ cellIndex, width }) => {
- const cell = cells[cellIndex];
- if (columnWidths[cellIndex] !== undefined)
- return `${columnWidths[cellIndex] / (cell?.colSpan ?? 1)}px`;
- if (typeof width === 'number')
- return columnDefinitionsToCss([width], flexibleSubGridColumnWidth);
- const minimumWidth = widths[autoIndex] ?? minSubviewColumnWidth;
- const flex = autoColumns[autoIndex]?.width ?? minSubviewColumnWidth;
- autoIndex += 1;
- return `minmax(${minimumWidth}px, ${flex}fr)`;
- }),
- ...(collapsedViewDefinition?.columns.slice(cellColumns) ?? []).map(
- (width) =>
- typeof width === 'number'
- ? columnDefinitionsToCss([width], flexibleSubGridColumnWidth)
- : 'minmax(0, 1fr)'
- ),
- autoColumns.length === 0 ? 'minmax(0, 1fr)' : '0px',
- 'min-content',
- ].join(' ');
- }, [
- collapsedViewDefinition?.columns,
- collapsedViewDefinition?.rows,
- columnWidths,
- contentColumnWidths,
- headerColumnWidths,
- sortConfig,
+ const columnSizing = useFormTableColumns({
flexibleSubGridColumnWidth,
- tableChromeWidth,
- tableWidth,
- ]);
+ hasResources: resources.length > 0,
+ isCollapsed,
+ measurementKey: unsortedResources,
+ sortConfig,
+ viewDefinition: collapsedViewDefinition,
+ });
+ const {
+ gridRef: scrollerRef,
+ gridTemplateColumns,
+ scrollContainerRef,
+ } = columnSizing;
const { isFetching, handleScroll } = useInfiniteScroll(
handleFetchMore,
scrollContainerRef
@@ -737,50 +397,9 @@ export function FormTable({
) : (
label
)}
- {
- if (
- event.key !== 'ArrowLeft' &&
- event.key !== 'ArrowRight'
- )
- return;
- event.preventDefault();
- const header =
- scrollerRef.current?.querySelector
(
- `[data-subview-header-col="${columnIndex}"]`
- );
- const currentWidth =
- columnWidths[columnIndex] ??
- header?.getBoundingClientRect().width ??
- minSubviewColumnWidth;
- setColumnWidths((widths) => ({
- ...widths,
- [columnIndex]: Math.max(
- 60,
- Math.min(
- maxSubviewColumnWidth,
- currentWidth +
- (event.key === 'ArrowRight' ? 10 : -10)
- )
- ),
- }));
- }}
- onPointerDown={(event): void =>
- resizeColumn(columnIndex, event)
- }
+
);
diff --git a/specifyweb/frontend/js_src/lib/components/FormCells/useFormTableColumns.tsx b/specifyweb/frontend/js_src/lib/components/FormCells/useFormTableColumns.tsx
new file mode 100644
index 00000000000..00d7c62e461
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/components/FormCells/useFormTableColumns.tsx
@@ -0,0 +1,462 @@
+import React from 'react';
+
+import { columnDefinitionsToCss } from '../Atoms/DataEntry';
+import { backboneFieldSeparator } from '../DataModel/helpers';
+import type { ParsedFormDefinition } from '../FormParse';
+import type { SortConfig } from '../Molecules/Sorting';
+
+const minimumColumnWidth = 80;
+const minimumResizableColumnWidth = 60;
+const maximumColumnWidth = 600;
+
+function measureText(
+ 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 measureHeader(
+ text: string,
+ font: string,
+ context: CanvasRenderingContext2D
+): number {
+ const words = text.trim().split(/\s+/u);
+ const middle = Math.ceil(words.length / 2);
+ return measureText(
+ `${words.slice(0, middle).join(' ')}\n${words.slice(middle).join(' ')}`,
+ font,
+ context
+ );
+}
+
+function measureCell(
+ cell: HTMLElement,
+ context: CanvasRenderingContext2D
+): number {
+ const controls = Array.from(
+ cell.querySelectorAll<
+ HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
+ >('input, textarea, select')
+ );
+ if (controls.length === 0)
+ return measureText(
+ 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 =
+ measureText(value, style.font, context) +
+ horizontalPadding +
+ (control instanceof HTMLSelectElement
+ ? 28
+ : control instanceof HTMLInputElement && control.type === 'number'
+ ? 30
+ : 8);
+ return Math.max(total, width);
+ }, minimumColumnWidth);
+}
+
+function fitColumnWidths(
+ widths: Array,
+ availableWidth: number,
+ minimumWidths: Array
+): Array {
+ const minimumTotal = minimumWidths.reduce((total, width) => total + width, 0);
+ const preferredWidths = widths.map((width, index) =>
+ Math.max(width, minimumWidths[index] ?? minimumColumnWidth)
+ );
+ const totalWidth = preferredWidths.reduce((total, width) => total + width, 0);
+ if (totalWidth <= availableWidth) return preferredWidths;
+ if (minimumTotal === 0) return minimumWidths;
+ if (availableWidth <= minimumTotal)
+ return minimumWidths.map(
+ (width) => (width * availableWidth) / minimumTotal
+ );
+ const scale = (availableWidth - minimumTotal) / (totalWidth - minimumTotal);
+ return preferredWidths.map((width, index) => {
+ const minimum = minimumWidths[index] ?? minimumColumnWidth;
+ return minimum + (width - minimum) * scale;
+ });
+}
+
+type FormTableColumnSizing = {
+ readonly columnWidths: Record;
+ readonly contentColumnWidths: Array;
+ readonly gridRef: React.RefObject;
+ readonly gridTemplateColumns: string;
+ readonly resizeColumn: (
+ columnIndex: number,
+ event: React.PointerEvent
+ ) => void;
+ readonly resizeColumnWithKeyboard: (
+ columnIndex: number,
+ direction: -1 | 1
+ ) => void;
+ readonly scrollContainerRef: React.RefObject;
+};
+
+export function useFormTableColumns({
+ flexibleSubGridColumnWidth,
+ hasResources,
+ isCollapsed,
+ measurementKey,
+ sortConfig,
+ viewDefinition,
+}: {
+ readonly flexibleSubGridColumnWidth: boolean;
+ readonly hasResources: boolean;
+ readonly isCollapsed: boolean;
+ readonly measurementKey: unknown;
+ readonly sortConfig: SortConfig | undefined;
+ readonly viewDefinition: ParsedFormDefinition | undefined;
+}): FormTableColumnSizing {
+ const gridRef = React.useRef(null);
+ const scrollContainerRef = React.useRef(null);
+ const [tableWidth, setTableWidth] = React.useState(0);
+ const [contentColumnWidths, setContentColumnWidths] = React.useState<
+ Array
+ >([]);
+ const [headerColumnWidths, setHeaderColumnWidths] = React.useState<
+ Array
+ >([]);
+ const [tableChromeWidth, setTableChromeWidth] = React.useState(0);
+ const [columnWidths, setColumnWidths] = React.useState<
+ Record
+ >({});
+
+ React.useEffect(() => {
+ const scrollContainer = scrollContainerRef.current;
+ if (scrollContainer === null) return;
+ const observer = new ResizeObserver(([entry]) => {
+ setTableWidth(Math.floor(entry.contentRect.width));
+ });
+ observer.observe(scrollContainer);
+ return (): void => observer.disconnect();
+ }, [viewDefinition, hasResources, isCollapsed]);
+
+ React.useEffect(() => {
+ if (viewDefinition === undefined) return;
+ const frame = requestAnimationFrame(() => {
+ const tableElement = gridRef.current;
+ if (tableElement === null) return;
+ const context = document.createElement('canvas').getContext('2d');
+ if (context === null) return;
+ const measuredColumns = viewDefinition.rows[0].map((_, columnIndex) => {
+ const header = tableElement.querySelector(
+ `[data-subview-header-col="${columnIndex}"]`
+ );
+ const headerLabel = header?.querySelector(
+ '[data-subview-header-label]'
+ );
+ const cells = Array.from(
+ tableElement.querySelectorAll(
+ `[data-subview-cell-col="${columnIndex}"]`
+ )
+ );
+ const labelWidth =
+ headerLabel === null || headerLabel === undefined
+ ? 0
+ : measureHeader(
+ headerLabel.textContent ?? '',
+ getComputedStyle(headerLabel).font,
+ context
+ ) + 32;
+ const indicator = header?.querySelector(
+ '[data-subview-sort-indicator]'
+ );
+ const headerWidth =
+ labelWidth + (indicator === undefined || indicator === null ? 0 : 28);
+ return {
+ headerWidth: Math.min(maximumColumnWidth, headerWidth),
+ contentWidth: Math.min(
+ maximumColumnWidth,
+ Math.max(
+ minimumColumnWidth,
+ labelWidth,
+ ...cells.map((cell) => measureCell(cell, context))
+ )
+ ),
+ };
+ });
+ setHeaderColumnWidths(
+ measuredColumns.map(({ headerWidth }) =>
+ Math.max(minimumColumnWidth, headerWidth)
+ )
+ );
+ const headers = tableElement.querySelectorAll(
+ '[role="columnheader"]'
+ );
+ const actionButton = tableElement.querySelector(
+ '[role="rowgroup"] [role="row"] [role="cell"]:last-child button'
+ );
+ const buttonColumnsWidth =
+ (headers[0]?.getBoundingClientRect().width ?? 40) +
+ Math.max(40, actionButton?.getBoundingClientRect().width ?? 0);
+ 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 * (viewDefinition.columns.length + 2)
+ );
+ setContentColumnWidths(
+ measuredColumns.map(({ contentWidth }) => contentWidth)
+ );
+ });
+ return (): void => cancelAnimationFrame(frame);
+ }, [viewDefinition, isCollapsed, measurementKey]);
+
+ const resizeCleanupRef = React.useRef<(() => void) | undefined>(undefined);
+ React.useEffect(() => (): void => resizeCleanupRef.current?.(), []);
+
+ const resizeColumn = React.useCallback(
+ (columnIndex: number, event: React.PointerEvent): void => {
+ event.preventDefault();
+ event.stopPropagation();
+ resizeCleanupRef.current?.();
+ resizeCleanupRef.current = undefined;
+ const tableElement = gridRef.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;
+ const pointerId = event.pointerId;
+ let latestX = startX;
+ let frame: number | undefined;
+ const updateWidth = (clientX: number): void =>
+ setColumnWidths((widths) => ({
+ ...widths,
+ [columnIndex]: Math.max(
+ minimumResizableColumnWidth,
+ Math.min(
+ maximumColumnWidth,
+ Math.ceil(initialWidth + clientX - startX)
+ )
+ ),
+ }));
+ const handleMove = (moveEvent: PointerEvent): void => {
+ if (moveEvent.pointerId !== pointerId) return;
+ latestX = moveEvent.clientX;
+ if (frame !== undefined) return;
+ frame = requestAnimationFrame(() => {
+ frame = undefined;
+ updateWidth(latestX);
+ });
+ };
+ const handleUp = (upEvent: PointerEvent): void => {
+ if (upEvent.pointerId !== pointerId) return;
+ cleanup();
+ updateWidth(latestX);
+ };
+ const cleanup = (): void => {
+ if (frame !== undefined) cancelAnimationFrame(frame);
+ globalThis.removeEventListener('pointermove', handleMove);
+ globalThis.removeEventListener('pointerup', handleUp);
+ globalThis.removeEventListener('pointercancel', handleUp);
+ if (resizeCleanupRef.current === cleanup)
+ resizeCleanupRef.current = undefined;
+ };
+ globalThis.addEventListener('pointermove', handleMove);
+ globalThis.addEventListener('pointerup', handleUp);
+ globalThis.addEventListener('pointercancel', handleUp);
+ resizeCleanupRef.current = cleanup;
+ },
+ [columnWidths]
+ );
+
+ const resizeColumnWithKeyboard = React.useCallback(
+ (columnIndex: number, direction: -1 | 1): void => {
+ const header = gridRef.current?.querySelector(
+ `[data-subview-header-col="${columnIndex}"]`
+ );
+ const currentWidth =
+ columnWidths[columnIndex] ??
+ header?.getBoundingClientRect().width ??
+ minimumColumnWidth;
+ setColumnWidths((widths) => ({
+ ...widths,
+ [columnIndex]: Math.max(
+ minimumResizableColumnWidth,
+ Math.min(maximumColumnWidth, currentWidth + direction * 10)
+ ),
+ }));
+ },
+ [columnWidths]
+ );
+
+ const gridTemplateColumns = React.useMemo(() => {
+ const cells = viewDefinition?.rows[0] ?? [];
+ const definitions = viewDefinition?.columns ?? [];
+ let trackIndex = 0;
+ const tracks = cells.flatMap((cell, cellIndex) => {
+ const span = cell.colSpan;
+ return Array.from({ length: span }, () => {
+ const index = trackIndex++;
+ const customWidth = definitions[index];
+ return {
+ cellIndex,
+ width:
+ columnWidths[cellIndex] === undefined ? customWidth : undefined,
+ };
+ });
+ });
+ const autoColumns = tracks
+ .filter(
+ ({ width, cellIndex }) =>
+ typeof width !== 'number' && columnWidths[cellIndex] === undefined
+ )
+ .map(({ cellIndex }) => ({
+ cellIndex,
+ width:
+ (contentColumnWidths[cellIndex] ?? minimumColumnWidth) /
+ (cells[cellIndex]?.colSpan ?? 1),
+ }));
+ const fixedWidth = Object.values(columnWidths).reduce(
+ (total, width) => total + width,
+ 0
+ );
+ const customFixedWidth = tracks.reduce(
+ (total, { cellIndex, width }) =>
+ total +
+ (typeof width !== 'number'
+ ? 0
+ : flexibleSubGridColumnWidth
+ ? (contentColumnWidths[cellIndex] ?? minimumColumnWidth) /
+ (cells[cellIndex]?.colSpan ?? 1)
+ : width),
+ 0
+ );
+ const minimumWidths = autoColumns.map(({ cellIndex }) => {
+ const definition = cells[cellIndex];
+ const fieldName =
+ definition !== undefined && 'fieldNames' in definition
+ ? definition.fieldNames?.join(backboneFieldSeparator)
+ : undefined;
+ return sortConfig?.sortField === fieldName
+ ? Math.max(
+ minimumColumnWidth,
+ (headerColumnWidths[cellIndex] ?? minimumColumnWidth) /
+ (definition?.colSpan ?? 1)
+ )
+ : minimumColumnWidth;
+ });
+ const widths = fitColumnWidths(
+ autoColumns.map(({ width }) => width),
+ Math.max(
+ 0,
+ tableWidth - tableChromeWidth - fixedWidth - customFixedWidth
+ ),
+ minimumWidths
+ );
+ const cellColumns = cells.reduce((total, cell) => total + cell.colSpan, 0);
+ let autoIndex = 0;
+ return [
+ 'min-content',
+ ...tracks.map(({ cellIndex, width }) => {
+ const cell = cells[cellIndex];
+ if (columnWidths[cellIndex] !== undefined)
+ return `${columnWidths[cellIndex] / (cell?.colSpan ?? 1)}px`;
+ if (typeof width === 'number')
+ return columnDefinitionsToCss([width], flexibleSubGridColumnWidth);
+ const minimumWidth = widths[autoIndex] ?? minimumColumnWidth;
+ const flex = autoColumns[autoIndex]?.width ?? minimumColumnWidth;
+ autoIndex += 1;
+ return `minmax(${minimumWidth}px, ${flex}fr)`;
+ }),
+ ...(viewDefinition?.columns.slice(cellColumns) ?? []).map((width) =>
+ typeof width === 'number'
+ ? columnDefinitionsToCss([width], flexibleSubGridColumnWidth)
+ : 'minmax(0, 1fr)'
+ ),
+ autoColumns.length === 0 ? 'minmax(0, 1fr)' : '0px',
+ 'min-content',
+ ].join(' ');
+ }, [
+ viewDefinition?.columns,
+ viewDefinition?.rows,
+ columnWidths,
+ contentColumnWidths,
+ headerColumnWidths,
+ sortConfig,
+ flexibleSubGridColumnWidth,
+ tableChromeWidth,
+ tableWidth,
+ ]);
+
+ return {
+ columnWidths,
+ contentColumnWidths,
+ gridRef,
+ gridTemplateColumns,
+ resizeColumn,
+ resizeColumnWithKeyboard,
+ scrollContainerRef,
+ };
+}
+
+export function FormTableColumnResizeHandle({
+ columnIndex,
+ columnSizing,
+}: {
+ readonly columnIndex: number;
+ readonly columnSizing: FormTableColumnSizing;
+}): JSX.Element {
+ return (
+ {
+ if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
+ event.preventDefault();
+ columnSizing.resizeColumnWithKeyboard(
+ columnIndex,
+ event.key === 'ArrowRight' ? 1 : -1
+ );
+ }}
+ onPointerDown={(event): void =>
+ columnSizing.resizeColumn(columnIndex, event)
+ }
+ />
+ );
+}