@prairielearn/ui 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # @prairielearn/ui
2
2
 
3
+ ## 3.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - 3914bb4: Upgrade to Node 24
8
+
9
+ ### Patch Changes
10
+
11
+ - f1da6ea: Make `useAutoSizeColumns` compatible with React 18+
12
+ - @prairielearn/browser-utils@2.6.2
13
+
3
14
  ## 2.0.0
4
15
 
5
16
  ### Major Changes
@@ -1 +1 @@
1
- {"version":3,"file":"useAutoSizeColumns.d.ts","sourceRoot":"","sources":["../../src/components/useAutoSizeColumns.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAqB,MAAM,EAAE,KAAK,EAAE,MAAM,uBAAuB,CAAC;AAC9E,OAAO,EAAE,KAAK,GAAG,EAAE,KAAK,SAAS,EAAwC,MAAM,OAAO,CAAC;AAmDvF;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EACtC,KAAK,EAAE,KAAK,CAAC,KAAK,CAAC,EACnB,QAAQ,EAAE,SAAS,CAAC,cAAc,GAAG,IAAI,CAAC,EAC1C,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,CAAA;CAAE,KAAK,GAAG,CAAC,OAAO,CAAC,GACnF,OAAO,CAoGT","sourcesContent":["import type { ColumnSizingState, Header, Table } from '@tanstack/react-table';\nimport { type JSX, type RefObject, useEffect, useMemo, useRef, useState } from 'react';\nimport { type Root, createRoot } from 'react-dom/client';\n\nimport { TanstackTableHeaderCell } from './TanstackTableHeaderCell.js';\n\nfunction HiddenMeasurementHeader<TData>({\n table,\n columnsToMeasure,\n filters = {},\n}: {\n table: Table<TData>;\n columnsToMeasure: { id: string }[];\n filters?: Record<string, (props: { header: Header<TData, unknown> }) => JSX.Element>;\n}) {\n const headerGroups = table.getHeaderGroups();\n const leafHeaderGroup = headerGroups[headerGroups.length - 1];\n\n return (\n <div\n style={{\n position: 'fixed',\n visibility: 'hidden',\n pointerEvents: 'none',\n top: '-9999px',\n }}\n >\n <table className=\"table table-hover mb-0\" style={{ display: 'grid', tableLayout: 'fixed' }}>\n <thead style={{ display: 'grid' }}>\n <tr style={{ display: 'flex' }}>\n {columnsToMeasure.map((col) => {\n const header = leafHeaderGroup.headers.find((h) => h.column.id === col.id);\n if (!header) return null;\n\n return (\n <TanstackTableHeaderCell\n key={header.id}\n header={header}\n filters={filters}\n table={table}\n isPinned={false}\n measurementMode={true}\n />\n );\n })}\n </tr>\n </thead>\n </table>\n </div>\n );\n}\n\n/**\n * Custom hook that automatically measures and sets column widths based on header content.\n * Only measures columns that have `meta: { autoSize: true }` and don't have explicit sizes set.\n * User resizes are preserved.\n *\n * @param table - The TanStack Table instance\n * @param tableRef - Ref to the table container element\n * @param filters - Optional filters map for rendering filter components in measurement\n * @returns A boolean indicating whether the initial measurement has completed\n */\nexport function useAutoSizeColumns<TData>(\n table: Table<TData>,\n tableRef: RefObject<HTMLDivElement | null>,\n filters?: Record<string, (props: { header: Header<TData, unknown> }) => JSX.Element>,\n): boolean {\n const measurementContainerRef = useRef<HTMLDivElement | null>(null);\n const measurementRootRef = useRef<Root | null>(null);\n\n // Compute columns that need measuring\n const columnsToMeasure = useMemo(() => {\n const allColumns = table.getAllLeafColumns();\n return allColumns.filter((col) => col.columnDef.meta?.autoSize);\n }, [table]);\n\n // Initialize hasMeasured to true if there's nothing to measure\n const [hasMeasured, setHasMeasured] = useState(() => columnsToMeasure.length === 0);\n\n // Perform measurement\n useEffect(() => {\n if (hasMeasured || !tableRef.current || columnsToMeasure.length === 0) {\n return;\n }\n\n // Wait for next frame to ensure DOM is ready\n const rafId = requestAnimationFrame(() => {\n if (!tableRef.current) {\n return;\n }\n\n // Create or reuse measurement container\n let container = measurementContainerRef.current;\n if (!container) {\n container = document.createElement('div');\n document.body.append(container);\n measurementContainerRef.current = container;\n measurementRootRef.current = createRoot(container);\n }\n\n // Render headers into hidden container\n measurementRootRef.current?.render(\n <HiddenMeasurementHeader\n table={table}\n columnsToMeasure={columnsToMeasure}\n filters={filters ?? {}}\n />,\n );\n\n // Force layout calculation\n void container.offsetWidth;\n\n // Measure each header and build sizing state\n const newSizing: ColumnSizingState = {};\n\n for (const col of columnsToMeasure) {\n const headerElement = container.querySelector(\n `th[data-column-id=\"${col.id}\"]`,\n ) as HTMLElement;\n\n if (headerElement) {\n const measuredWidth = headerElement.scrollWidth;\n const resizeHandlePadding = col.getCanResize() ? 4 : 0;\n const minSize = col.columnDef.minSize ?? 0;\n const maxSize = col.columnDef.maxSize ?? Infinity;\n\n const finalWidth = Math.max(\n minSize,\n Math.min(maxSize, measuredWidth + resizeHandlePadding),\n );\n\n newSizing[col.id] = finalWidth;\n }\n }\n\n // Clear container content by unmounting React components\n measurementRootRef.current?.unmount();\n measurementRootRef.current = null;\n\n // Apply measurements\n if (Object.keys(newSizing).length > 0) {\n table.setColumnSizing((prev) => ({ ...prev, ...newSizing }));\n }\n\n setHasMeasured(true);\n });\n\n return () => {\n cancelAnimationFrame(rafId);\n };\n }, [table, tableRef, filters, hasMeasured, columnsToMeasure]);\n\n // Clean up measurement container on unmount\n useEffect(() => {\n return () => {\n measurementRootRef.current?.unmount();\n measurementRootRef.current = null;\n const container = measurementContainerRef.current;\n if (container) {\n container.remove();\n measurementContainerRef.current = null;\n }\n };\n }, []);\n\n return hasMeasured;\n}\n"]}
1
+ {"version":3,"file":"useAutoSizeColumns.d.ts","sourceRoot":"","sources":["../../src/components/useAutoSizeColumns.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAqB,MAAM,EAAE,KAAK,EAAE,MAAM,uBAAuB,CAAC;AAC9E,OAAO,EAAE,KAAK,GAAG,EAAE,KAAK,SAAS,EAAwC,MAAM,OAAO,CAAC;AAoDvF;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EACtC,KAAK,EAAE,KAAK,CAAC,KAAK,CAAC,EACnB,QAAQ,EAAE,SAAS,CAAC,cAAc,GAAG,IAAI,CAAC,EAC1C,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,CAAA;CAAE,KAAK,GAAG,CAAC,OAAO,CAAC,GACnF,OAAO,CAwGT","sourcesContent":["import type { ColumnSizingState, Header, Table } from '@tanstack/react-table';\nimport { type JSX, type RefObject, useEffect, useMemo, useRef, useState } from 'react';\nimport { flushSync } from 'react-dom';\nimport { type Root, createRoot } from 'react-dom/client';\n\nimport { TanstackTableHeaderCell } from './TanstackTableHeaderCell.js';\n\nfunction HiddenMeasurementHeader<TData>({\n table,\n columnsToMeasure,\n filters = {},\n}: {\n table: Table<TData>;\n columnsToMeasure: { id: string }[];\n filters?: Record<string, (props: { header: Header<TData, unknown> }) => JSX.Element>;\n}) {\n const headerGroups = table.getHeaderGroups();\n const leafHeaderGroup = headerGroups[headerGroups.length - 1];\n\n return (\n <div\n style={{\n position: 'fixed',\n visibility: 'hidden',\n pointerEvents: 'none',\n top: '-9999px',\n }}\n >\n <table className=\"table table-hover mb-0\" style={{ display: 'grid', tableLayout: 'fixed' }}>\n <thead style={{ display: 'grid' }}>\n <tr style={{ display: 'flex' }}>\n {columnsToMeasure.map((col) => {\n const header = leafHeaderGroup.headers.find((h) => h.column.id === col.id);\n if (!header) return null;\n\n return (\n <TanstackTableHeaderCell\n key={header.id}\n header={header}\n filters={filters}\n table={table}\n isPinned={false}\n measurementMode={true}\n />\n );\n })}\n </tr>\n </thead>\n </table>\n </div>\n );\n}\n\n/**\n * Custom hook that automatically measures and sets column widths based on header content.\n * Only measures columns that have `meta: { autoSize: true }` and don't have explicit sizes set.\n * User resizes are preserved.\n *\n * @param table - The TanStack Table instance\n * @param tableRef - Ref to the table container element\n * @param filters - Optional filters map for rendering filter components in measurement\n * @returns A boolean indicating whether the initial measurement has completed\n */\nexport function useAutoSizeColumns<TData>(\n table: Table<TData>,\n tableRef: RefObject<HTMLDivElement | null>,\n filters?: Record<string, (props: { header: Header<TData, unknown> }) => JSX.Element>,\n): boolean {\n const measurementContainerRef = useRef<HTMLDivElement | null>(null);\n const measurementRootRef = useRef<Root | null>(null);\n\n // Compute columns that need measuring\n const columnsToMeasure = useMemo(() => {\n const allColumns = table.getAllLeafColumns();\n return allColumns.filter((col) => col.columnDef.meta?.autoSize);\n }, [table]);\n\n // Initialize hasMeasured to true if there's nothing to measure\n const [hasMeasured, setHasMeasured] = useState(() => columnsToMeasure.length === 0);\n\n // Perform measurement\n useEffect(() => {\n if (hasMeasured || !tableRef.current || columnsToMeasure.length === 0) {\n return;\n }\n\n // Wait for next frame to ensure DOM is ready\n const rafId = requestAnimationFrame(() => {\n if (!tableRef.current) {\n return;\n }\n\n // Create or reuse measurement container\n let container = measurementContainerRef.current;\n if (!container) {\n container = document.createElement('div');\n document.body.append(container);\n measurementContainerRef.current = container;\n measurementRootRef.current = createRoot(container);\n }\n\n // Render headers into hidden container. We need to use `flushSync` to ensure\n // that it's rendered synchronously before we measure.\n // eslint-disable-next-line @eslint-react/dom/no-flush-sync\n flushSync(() => {\n measurementRootRef.current?.render(\n <HiddenMeasurementHeader\n table={table}\n columnsToMeasure={columnsToMeasure}\n filters={filters ?? {}}\n />,\n );\n });\n\n // Force layout calculation\n void container.offsetWidth;\n\n // Measure each header and build sizing state\n const newSizing: ColumnSizingState = {};\n\n for (const col of columnsToMeasure) {\n const headerElement = container.querySelector(\n `th[data-column-id=\"${col.id}\"]`,\n ) as HTMLElement;\n\n if (headerElement) {\n const measuredWidth = headerElement.scrollWidth;\n const resizeHandlePadding = col.getCanResize() ? 4 : 0;\n const minSize = col.columnDef.minSize ?? 0;\n const maxSize = col.columnDef.maxSize ?? Infinity;\n\n const finalWidth = Math.max(\n minSize,\n Math.min(maxSize, measuredWidth + resizeHandlePadding),\n );\n\n newSizing[col.id] = finalWidth;\n }\n }\n\n // Clear container content by unmounting React components\n measurementRootRef.current?.unmount();\n measurementRootRef.current = null;\n\n // Apply measurements\n if (Object.keys(newSizing).length > 0) {\n table.setColumnSizing((prev) => ({ ...prev, ...newSizing }));\n }\n\n setHasMeasured(true);\n });\n\n return () => {\n cancelAnimationFrame(rafId);\n };\n }, [table, tableRef, filters, hasMeasured, columnsToMeasure]);\n\n // Clean up measurement container on unmount\n useEffect(() => {\n return () => {\n measurementRootRef.current?.unmount();\n measurementRootRef.current = null;\n const container = measurementContainerRef.current;\n if (container) {\n container.remove();\n measurementContainerRef.current = null;\n }\n };\n }, []);\n\n return hasMeasured;\n}\n"]}
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useEffect, useMemo, useRef, useState } from 'react';
3
+ import { flushSync } from 'react-dom';
3
4
  import { createRoot } from 'react-dom/client';
4
5
  import { TanstackTableHeaderCell } from './TanstackTableHeaderCell.js';
5
6
  function HiddenMeasurementHeader({ table, columnsToMeasure, filters = {}, }) {
@@ -55,8 +56,12 @@ export function useAutoSizeColumns(table, tableRef, filters) {
55
56
  measurementContainerRef.current = container;
56
57
  measurementRootRef.current = createRoot(container);
57
58
  }
58
- // Render headers into hidden container
59
- measurementRootRef.current?.render(_jsx(HiddenMeasurementHeader, { table: table, columnsToMeasure: columnsToMeasure, filters: filters ?? {} }));
59
+ // Render headers into hidden container. We need to use `flushSync` to ensure
60
+ // that it's rendered synchronously before we measure.
61
+ // eslint-disable-next-line @eslint-react/dom/no-flush-sync
62
+ flushSync(() => {
63
+ measurementRootRef.current?.render(_jsx(HiddenMeasurementHeader, { table: table, columnsToMeasure: columnsToMeasure, filters: filters ?? {} }));
64
+ });
60
65
  // Force layout calculation
61
66
  void container.offsetWidth;
62
67
  // Measure each header and build sizing state
@@ -1 +1 @@
1
- {"version":3,"file":"useAutoSizeColumns.js","sourceRoot":"","sources":["../../src/components/useAutoSizeColumns.tsx"],"names":[],"mappings":"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","sourcesContent":["import type { ColumnSizingState, Header, Table } from '@tanstack/react-table';\nimport { type JSX, type RefObject, useEffect, useMemo, useRef, useState } from 'react';\nimport { type Root, createRoot } from 'react-dom/client';\n\nimport { TanstackTableHeaderCell } from './TanstackTableHeaderCell.js';\n\nfunction HiddenMeasurementHeader<TData>({\n table,\n columnsToMeasure,\n filters = {},\n}: {\n table: Table<TData>;\n columnsToMeasure: { id: string }[];\n filters?: Record<string, (props: { header: Header<TData, unknown> }) => JSX.Element>;\n}) {\n const headerGroups = table.getHeaderGroups();\n const leafHeaderGroup = headerGroups[headerGroups.length - 1];\n\n return (\n <div\n style={{\n position: 'fixed',\n visibility: 'hidden',\n pointerEvents: 'none',\n top: '-9999px',\n }}\n >\n <table className=\"table table-hover mb-0\" style={{ display: 'grid', tableLayout: 'fixed' }}>\n <thead style={{ display: 'grid' }}>\n <tr style={{ display: 'flex' }}>\n {columnsToMeasure.map((col) => {\n const header = leafHeaderGroup.headers.find((h) => h.column.id === col.id);\n if (!header) return null;\n\n return (\n <TanstackTableHeaderCell\n key={header.id}\n header={header}\n filters={filters}\n table={table}\n isPinned={false}\n measurementMode={true}\n />\n );\n })}\n </tr>\n </thead>\n </table>\n </div>\n );\n}\n\n/**\n * Custom hook that automatically measures and sets column widths based on header content.\n * Only measures columns that have `meta: { autoSize: true }` and don't have explicit sizes set.\n * User resizes are preserved.\n *\n * @param table - The TanStack Table instance\n * @param tableRef - Ref to the table container element\n * @param filters - Optional filters map for rendering filter components in measurement\n * @returns A boolean indicating whether the initial measurement has completed\n */\nexport function useAutoSizeColumns<TData>(\n table: Table<TData>,\n tableRef: RefObject<HTMLDivElement | null>,\n filters?: Record<string, (props: { header: Header<TData, unknown> }) => JSX.Element>,\n): boolean {\n const measurementContainerRef = useRef<HTMLDivElement | null>(null);\n const measurementRootRef = useRef<Root | null>(null);\n\n // Compute columns that need measuring\n const columnsToMeasure = useMemo(() => {\n const allColumns = table.getAllLeafColumns();\n return allColumns.filter((col) => col.columnDef.meta?.autoSize);\n }, [table]);\n\n // Initialize hasMeasured to true if there's nothing to measure\n const [hasMeasured, setHasMeasured] = useState(() => columnsToMeasure.length === 0);\n\n // Perform measurement\n useEffect(() => {\n if (hasMeasured || !tableRef.current || columnsToMeasure.length === 0) {\n return;\n }\n\n // Wait for next frame to ensure DOM is ready\n const rafId = requestAnimationFrame(() => {\n if (!tableRef.current) {\n return;\n }\n\n // Create or reuse measurement container\n let container = measurementContainerRef.current;\n if (!container) {\n container = document.createElement('div');\n document.body.append(container);\n measurementContainerRef.current = container;\n measurementRootRef.current = createRoot(container);\n }\n\n // Render headers into hidden container\n measurementRootRef.current?.render(\n <HiddenMeasurementHeader\n table={table}\n columnsToMeasure={columnsToMeasure}\n filters={filters ?? {}}\n />,\n );\n\n // Force layout calculation\n void container.offsetWidth;\n\n // Measure each header and build sizing state\n const newSizing: ColumnSizingState = {};\n\n for (const col of columnsToMeasure) {\n const headerElement = container.querySelector(\n `th[data-column-id=\"${col.id}\"]`,\n ) as HTMLElement;\n\n if (headerElement) {\n const measuredWidth = headerElement.scrollWidth;\n const resizeHandlePadding = col.getCanResize() ? 4 : 0;\n const minSize = col.columnDef.minSize ?? 0;\n const maxSize = col.columnDef.maxSize ?? Infinity;\n\n const finalWidth = Math.max(\n minSize,\n Math.min(maxSize, measuredWidth + resizeHandlePadding),\n );\n\n newSizing[col.id] = finalWidth;\n }\n }\n\n // Clear container content by unmounting React components\n measurementRootRef.current?.unmount();\n measurementRootRef.current = null;\n\n // Apply measurements\n if (Object.keys(newSizing).length > 0) {\n table.setColumnSizing((prev) => ({ ...prev, ...newSizing }));\n }\n\n setHasMeasured(true);\n });\n\n return () => {\n cancelAnimationFrame(rafId);\n };\n }, [table, tableRef, filters, hasMeasured, columnsToMeasure]);\n\n // Clean up measurement container on unmount\n useEffect(() => {\n return () => {\n measurementRootRef.current?.unmount();\n measurementRootRef.current = null;\n const container = measurementContainerRef.current;\n if (container) {\n container.remove();\n measurementContainerRef.current = null;\n }\n };\n }, []);\n\n return hasMeasured;\n}\n"]}
1
+ {"version":3,"file":"useAutoSizeColumns.js","sourceRoot":"","sources":["../../src/components/useAutoSizeColumns.tsx"],"names":[],"mappings":";AACA,OAAO,EAA4B,SAAS,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AACvF,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AACtC,OAAO,EAAa,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAEzD,OAAO,EAAE,uBAAuB,EAAE,MAAM,8BAA8B,CAAC;AAEvE,SAAS,uBAAuB,CAAQ,EACtC,KAAK,EACL,gBAAgB,EAChB,OAAO,GAAG,EAAE,GAKb,EAAE;IACD,MAAM,YAAY,GAAG,KAAK,CAAC,eAAe,EAAE,CAAC;IAC7C,MAAM,eAAe,GAAG,YAAY,CAAC,YAAY,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;IAE9D,OAAO,CACL,cACE,KAAK,EAAE;YACL,QAAQ,EAAE,OAAO;YACjB,UAAU,EAAE,QAAQ;YACpB,aAAa,EAAE,MAAM;YACrB,GAAG,EAAE,SAAS;SACf,YAED,gBAAO,SAAS,EAAC,wBAAwB,EAAC,KAAK,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,YACxF,gBAAO,KAAK,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,YAC/B,aAAI,KAAK,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,YAC3B,gBAAgB,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC;wBAC7B,MAAM,MAAM,GAAG,eAAe,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,EAAE,KAAK,GAAG,CAAC,EAAE,CAAC,CAAC;wBAC3E,IAAI,CAAC,MAAM;4BAAE,OAAO,IAAI,CAAC;wBAEzB,OAAO,CACL,KAAC,uBAAuB,IAEtB,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,OAAO,EAChB,KAAK,EAAE,KAAK,EACZ,QAAQ,EAAE,KAAK,EACf,eAAe,EAAE,IAAI,IALhB,MAAM,CAAC,EAAE,CAMd,CACH,CAAC;oBAAA,CACH,CAAC,GACC,GACC,GACF,GACJ,CACP,CAAC;AAAA,CACH;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,kBAAkB,CAChC,KAAmB,EACnB,QAA0C,EAC1C,OAAoF,EAC3E;IACT,MAAM,uBAAuB,GAAG,MAAM,CAAwB,IAAI,CAAC,CAAC;IACpE,MAAM,kBAAkB,GAAG,MAAM,CAAc,IAAI,CAAC,CAAC;IAErD,sCAAsC;IACtC,MAAM,gBAAgB,GAAG,OAAO,CAAC,GAAG,EAAE,CAAC;QACrC,MAAM,UAAU,GAAG,KAAK,CAAC,iBAAiB,EAAE,CAAC;QAC7C,OAAO,UAAU,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,SAAS,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;IAAA,CACjE,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC;IAEZ,+DAA+D;IAC/D,MAAM,CAAC,WAAW,EAAE,cAAc,CAAC,GAAG,QAAQ,CAAC,GAAG,EAAE,CAAC,gBAAgB,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC;IAEpF,sBAAsB;IACtB,SAAS,CAAC,GAAG,EAAE,CAAC;QACd,IAAI,WAAW,IAAI,CAAC,QAAQ,CAAC,OAAO,IAAI,gBAAgB,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACtE,OAAO;QACT,CAAC;QAED,6CAA6C;QAC7C,MAAM,KAAK,GAAG,qBAAqB,CAAC,GAAG,EAAE,CAAC;YACxC,IAAI,CAAC,QAAQ,CAAC,OAAO,EAAE,CAAC;gBACtB,OAAO;YACT,CAAC;YAED,wCAAwC;YACxC,IAAI,SAAS,GAAG,uBAAuB,CAAC,OAAO,CAAC;YAChD,IAAI,CAAC,SAAS,EAAE,CAAC;gBACf,SAAS,GAAG,QAAQ,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;gBAC1C,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;gBAChC,uBAAuB,CAAC,OAAO,GAAG,SAAS,CAAC;gBAC5C,kBAAkB,CAAC,OAAO,GAAG,UAAU,CAAC,SAAS,CAAC,CAAC;YACrD,CAAC;YAED,6EAA6E;YAC7E,sDAAsD;YACtD,2DAA2D;YAC3D,SAAS,CAAC,GAAG,EAAE,CAAC;gBACd,kBAAkB,CAAC,OAAO,EAAE,MAAM,CAChC,KAAC,uBAAuB,IACtB,KAAK,EAAE,KAAK,EACZ,gBAAgB,EAAE,gBAAgB,EAClC,OAAO,EAAE,OAAO,IAAI,EAAE,GACtB,CACH,CAAC;YAAA,CACH,CAAC,CAAC;YAEH,2BAA2B;YAC3B,KAAK,SAAS,CAAC,WAAW,CAAC;YAE3B,6CAA6C;YAC7C,MAAM,SAAS,GAAsB,EAAE,CAAC;YAExC,KAAK,MAAM,GAAG,IAAI,gBAAgB,EAAE,CAAC;gBACnC,MAAM,aAAa,GAAG,SAAS,CAAC,aAAa,CAC3C,sBAAsB,GAAG,CAAC,EAAE,IAAI,CAClB,CAAC;gBAEjB,IAAI,aAAa,EAAE,CAAC;oBAClB,MAAM,aAAa,GAAG,aAAa,CAAC,WAAW,CAAC;oBAChD,MAAM,mBAAmB,GAAG,GAAG,CAAC,YAAY,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;oBACvD,MAAM,OAAO,GAAG,GAAG,CAAC,SAAS,CAAC,OAAO,IAAI,CAAC,CAAC;oBAC3C,MAAM,OAAO,GAAG,GAAG,CAAC,SAAS,CAAC,OAAO,IAAI,QAAQ,CAAC;oBAElD,MAAM,UAAU,GAAG,IAAI,CAAC,GAAG,CACzB,OAAO,EACP,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,aAAa,GAAG,mBAAmB,CAAC,CACvD,CAAC;oBAEF,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,UAAU,CAAC;gBACjC,CAAC;YACH,CAAC;YAED,yDAAyD;YACzD,kBAAkB,CAAC,OAAO,EAAE,OAAO,EAAE,CAAC;YACtC,kBAAkB,CAAC,OAAO,GAAG,IAAI,CAAC;YAElC,qBAAqB;YACrB,IAAI,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;gBACtC,KAAK,CAAC,eAAe,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,EAAE,GAAG,IAAI,EAAE,GAAG,SAAS,EAAE,CAAC,CAAC,CAAC;YAC/D,CAAC;YAED,cAAc,CAAC,IAAI,CAAC,CAAC;QAAA,CACtB,CAAC,CAAC;QAEH,OAAO,GAAG,EAAE,CAAC;YACX,oBAAoB,CAAC,KAAK,CAAC,CAAC;QAAA,CAC7B,CAAC;IAAA,CACH,EAAE,CAAC,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,WAAW,EAAE,gBAAgB,CAAC,CAAC,CAAC;IAE9D,4CAA4C;IAC5C,SAAS,CAAC,GAAG,EAAE,CAAC;QACd,OAAO,GAAG,EAAE,CAAC;YACX,kBAAkB,CAAC,OAAO,EAAE,OAAO,EAAE,CAAC;YACtC,kBAAkB,CAAC,OAAO,GAAG,IAAI,CAAC;YAClC,MAAM,SAAS,GAAG,uBAAuB,CAAC,OAAO,CAAC;YAClD,IAAI,SAAS,EAAE,CAAC;gBACd,SAAS,CAAC,MAAM,EAAE,CAAC;gBACnB,uBAAuB,CAAC,OAAO,GAAG,IAAI,CAAC;YACzC,CAAC;QAAA,CACF,CAAC;IAAA,CACH,EAAE,EAAE,CAAC,CAAC;IAEP,OAAO,WAAW,CAAC;AAAA,CACpB","sourcesContent":["import type { ColumnSizingState, Header, Table } from '@tanstack/react-table';\nimport { type JSX, type RefObject, useEffect, useMemo, useRef, useState } from 'react';\nimport { flushSync } from 'react-dom';\nimport { type Root, createRoot } from 'react-dom/client';\n\nimport { TanstackTableHeaderCell } from './TanstackTableHeaderCell.js';\n\nfunction HiddenMeasurementHeader<TData>({\n table,\n columnsToMeasure,\n filters = {},\n}: {\n table: Table<TData>;\n columnsToMeasure: { id: string }[];\n filters?: Record<string, (props: { header: Header<TData, unknown> }) => JSX.Element>;\n}) {\n const headerGroups = table.getHeaderGroups();\n const leafHeaderGroup = headerGroups[headerGroups.length - 1];\n\n return (\n <div\n style={{\n position: 'fixed',\n visibility: 'hidden',\n pointerEvents: 'none',\n top: '-9999px',\n }}\n >\n <table className=\"table table-hover mb-0\" style={{ display: 'grid', tableLayout: 'fixed' }}>\n <thead style={{ display: 'grid' }}>\n <tr style={{ display: 'flex' }}>\n {columnsToMeasure.map((col) => {\n const header = leafHeaderGroup.headers.find((h) => h.column.id === col.id);\n if (!header) return null;\n\n return (\n <TanstackTableHeaderCell\n key={header.id}\n header={header}\n filters={filters}\n table={table}\n isPinned={false}\n measurementMode={true}\n />\n );\n })}\n </tr>\n </thead>\n </table>\n </div>\n );\n}\n\n/**\n * Custom hook that automatically measures and sets column widths based on header content.\n * Only measures columns that have `meta: { autoSize: true }` and don't have explicit sizes set.\n * User resizes are preserved.\n *\n * @param table - The TanStack Table instance\n * @param tableRef - Ref to the table container element\n * @param filters - Optional filters map for rendering filter components in measurement\n * @returns A boolean indicating whether the initial measurement has completed\n */\nexport function useAutoSizeColumns<TData>(\n table: Table<TData>,\n tableRef: RefObject<HTMLDivElement | null>,\n filters?: Record<string, (props: { header: Header<TData, unknown> }) => JSX.Element>,\n): boolean {\n const measurementContainerRef = useRef<HTMLDivElement | null>(null);\n const measurementRootRef = useRef<Root | null>(null);\n\n // Compute columns that need measuring\n const columnsToMeasure = useMemo(() => {\n const allColumns = table.getAllLeafColumns();\n return allColumns.filter((col) => col.columnDef.meta?.autoSize);\n }, [table]);\n\n // Initialize hasMeasured to true if there's nothing to measure\n const [hasMeasured, setHasMeasured] = useState(() => columnsToMeasure.length === 0);\n\n // Perform measurement\n useEffect(() => {\n if (hasMeasured || !tableRef.current || columnsToMeasure.length === 0) {\n return;\n }\n\n // Wait for next frame to ensure DOM is ready\n const rafId = requestAnimationFrame(() => {\n if (!tableRef.current) {\n return;\n }\n\n // Create or reuse measurement container\n let container = measurementContainerRef.current;\n if (!container) {\n container = document.createElement('div');\n document.body.append(container);\n measurementContainerRef.current = container;\n measurementRootRef.current = createRoot(container);\n }\n\n // Render headers into hidden container. We need to use `flushSync` to ensure\n // that it's rendered synchronously before we measure.\n // eslint-disable-next-line @eslint-react/dom/no-flush-sync\n flushSync(() => {\n measurementRootRef.current?.render(\n <HiddenMeasurementHeader\n table={table}\n columnsToMeasure={columnsToMeasure}\n filters={filters ?? {}}\n />,\n );\n });\n\n // Force layout calculation\n void container.offsetWidth;\n\n // Measure each header and build sizing state\n const newSizing: ColumnSizingState = {};\n\n for (const col of columnsToMeasure) {\n const headerElement = container.querySelector(\n `th[data-column-id=\"${col.id}\"]`,\n ) as HTMLElement;\n\n if (headerElement) {\n const measuredWidth = headerElement.scrollWidth;\n const resizeHandlePadding = col.getCanResize() ? 4 : 0;\n const minSize = col.columnDef.minSize ?? 0;\n const maxSize = col.columnDef.maxSize ?? Infinity;\n\n const finalWidth = Math.max(\n minSize,\n Math.min(maxSize, measuredWidth + resizeHandlePadding),\n );\n\n newSizing[col.id] = finalWidth;\n }\n }\n\n // Clear container content by unmounting React components\n measurementRootRef.current?.unmount();\n measurementRootRef.current = null;\n\n // Apply measurements\n if (Object.keys(newSizing).length > 0) {\n table.setColumnSizing((prev) => ({ ...prev, ...newSizing }));\n }\n\n setHasMeasured(true);\n });\n\n return () => {\n cancelAnimationFrame(rafId);\n };\n }, [table, tableRef, filters, hasMeasured, columnsToMeasure]);\n\n // Clean up measurement container on unmount\n useEffect(() => {\n return () => {\n measurementRootRef.current?.unmount();\n measurementRootRef.current = null;\n const container = measurementContainerRef.current;\n if (container) {\n container.remove();\n measurementContainerRef.current = null;\n }\n };\n }, []);\n\n return hasMeasured;\n}\n"]}
package/package.json CHANGED
@@ -1,12 +1,15 @@
1
1
  {
2
2
  "name": "@prairielearn/ui",
3
- "version": "2.0.0",
3
+ "version": "3.0.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/PrairieLearn/PrairieLearn.git",
8
8
  "directory": "packages/ui"
9
9
  },
10
+ "engines": {
11
+ "node": ">=24.0.0"
12
+ },
10
13
  "exports": {
11
14
  ".": "./dist/index.js",
12
15
  "./*.css": "./dist/*.css"
@@ -17,7 +20,7 @@
17
20
  "test": "vitest run --coverage"
18
21
  },
19
22
  "dependencies": {
20
- "@prairielearn/browser-utils": "^2.6.1",
23
+ "@prairielearn/browser-utils": "^2.6.2",
21
24
  "@tanstack/react-table": "^8.21.3",
22
25
  "@tanstack/react-virtual": "^3.13.18",
23
26
  "@tanstack/table-core": "^8.21.3",
@@ -32,7 +35,7 @@
32
35
  },
33
36
  "devDependencies": {
34
37
  "@prairielearn/tsconfig": "^0.0.0",
35
- "@types/node": "^22.19.5",
38
+ "@types/node": "^24.10.9",
36
39
  "@typescript/native-preview": "^7.0.0-dev.20260106.1",
37
40
  "typescript": "^5.9.3",
38
41
  "typescript-cp": "^0.1.9",
@@ -1,5 +1,6 @@
1
1
  import type { ColumnSizingState, Header, Table } from '@tanstack/react-table';
2
2
  import { type JSX, type RefObject, useEffect, useMemo, useRef, useState } from 'react';
3
+ import { flushSync } from 'react-dom';
3
4
  import { type Root, createRoot } from 'react-dom/client';
4
5
 
5
6
  import { TanstackTableHeaderCell } from './TanstackTableHeaderCell.js';
@@ -98,14 +99,18 @@ export function useAutoSizeColumns<TData>(
98
99
  measurementRootRef.current = createRoot(container);
99
100
  }
100
101
 
101
- // Render headers into hidden container
102
- measurementRootRef.current?.render(
103
- <HiddenMeasurementHeader
104
- table={table}
105
- columnsToMeasure={columnsToMeasure}
106
- filters={filters ?? {}}
107
- />,
108
- );
102
+ // Render headers into hidden container. We need to use `flushSync` to ensure
103
+ // that it's rendered synchronously before we measure.
104
+ // eslint-disable-next-line @eslint-react/dom/no-flush-sync
105
+ flushSync(() => {
106
+ measurementRootRef.current?.render(
107
+ <HiddenMeasurementHeader
108
+ table={table}
109
+ columnsToMeasure={columnsToMeasure}
110
+ filters={filters ?? {}}
111
+ />,
112
+ );
113
+ });
109
114
 
110
115
  // Force layout calculation
111
116
  void container.offsetWidth;