@open-mercato/ui 0.6.7-develop.6553.1.2ec7246e04 → 0.6.7-develop.6566.1.15385fa179

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/ui",
3
- "version": "0.6.7-develop.6553.1.2ec7246e04",
3
+ "version": "0.6.7-develop.6566.1.15385fa179",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -155,13 +155,13 @@
155
155
  "remark-gfm": "^4.0.1"
156
156
  },
157
157
  "peerDependencies": {
158
- "@open-mercato/shared": "0.6.7-develop.6553.1.2ec7246e04",
158
+ "@open-mercato/shared": "0.6.7-develop.6566.1.15385fa179",
159
159
  "react": ">=18.0.0",
160
160
  "react-dom": ">=18.0.0",
161
161
  "react-is": ">=18.0.0"
162
162
  },
163
163
  "devDependencies": {
164
- "@open-mercato/shared": "0.6.7-develop.6553.1.2ec7246e04",
164
+ "@open-mercato/shared": "0.6.7-develop.6566.1.15385fa179",
165
165
  "@testing-library/dom": "^10.4.1",
166
166
  "@testing-library/jest-dom": "^6.9.1",
167
167
  "@testing-library/react": "^16.3.1",
@@ -174,7 +174,7 @@
174
174
  "react-dom": "19.2.7",
175
175
  "react-is": "^19.2.7",
176
176
  "ts-jest": "^29.4.11",
177
- "typescript": "^6.0.3"
177
+ "typescript": "7.0.2"
178
178
  },
179
179
  "publishConfig": {
180
180
  "access": "public"
@@ -471,6 +471,12 @@ function resolveExportSections(config: DataTableExportConfig | null | undefined)
471
471
  const PERSPECTIVE_COOKIE_PREFIX = 'om_table_perspective'
472
472
  const PERSPECTIVE_STORAGE_PREFIX = 'om_table_perspective_snapshot'
473
473
 
474
+ // Bounds for user-driven column resizing (#1835). Widths outside this range are
475
+ // clamped so a persisted/dragged value can never collapse a column to nothing or
476
+ // blow the table out horizontally.
477
+ const COLUMN_MIN_WIDTH = 60
478
+ const COLUMN_MAX_WIDTH = 900
479
+
474
480
  function formatDurationLabel(durationMs?: number | null): string {
475
481
  if (durationMs == null) return ''
476
482
  if (!Number.isFinite(durationMs)) return ''
@@ -544,7 +550,7 @@ export function writePerspectiveSnapshot(tableId: string, snapshot: PerspectiveS
544
550
  writeVersionedPreference(key, PERSPECTIVE_SNAPSHOT_VERSION, snapshot)
545
551
  }
546
552
 
547
- function sanitizePerspectiveSettings(source?: PerspectiveSettings | null): PerspectiveSettings | null {
553
+ export function sanitizePerspectiveSettings(source?: PerspectiveSettings | null): PerspectiveSettings | null {
548
554
  if (!source || typeof source !== 'object') return null
549
555
  const forbidden = new Set(['__proto__', 'prototype', 'constructor'])
550
556
  const result: PerspectiveSettings = {}
@@ -567,6 +573,17 @@ function sanitizePerspectiveSettings(source?: PerspectiveSettings | null): Persp
567
573
  }
568
574
  }
569
575
 
576
+ if (source.columnSizing && typeof source.columnSizing === 'object') {
577
+ const sizing: Record<string, number> = {}
578
+ for (const [key, value] of Object.entries(source.columnSizing)) {
579
+ const id = typeof key === 'string' ? key.trim() : ''
580
+ if (!id || forbidden.has(id)) continue
581
+ if (typeof value !== 'number' || !Number.isFinite(value)) continue
582
+ sizing[id] = Math.max(COLUMN_MIN_WIDTH, Math.min(COLUMN_MAX_WIDTH, Math.round(value)))
583
+ }
584
+ if (Object.keys(sizing).length) result.columnSizing = sizing
585
+ }
586
+
570
587
  if (Array.isArray(source.sorting)) {
571
588
  const sorting = source.sorting
572
589
  .map((item) => {
@@ -856,7 +873,99 @@ function HeaderDndWrapper({ enabled, contextId, sensors, columnIds, onDragEnd, c
856
873
  )
857
874
  }
858
875
 
859
- function SortableHeaderCell({ id, children, className }: { id: string; children: React.ReactNode; className?: string }) {
876
+ // Drag handle on a column header's right edge (#1835). Uses manual pointer
877
+ // tracking (mirrors the deals-pipeline LaneResizeHandle) rather than TanStack's
878
+ // built-in resize so the table keeps its auto layout — only user-resized columns
879
+ // get an explicit width, and dragging measures the header's real current width so
880
+ // there is no jump. `stopPropagation` on pointer/click keeps the header's
881
+ // reorder-DnD and sort-toggle from firing while resizing.
882
+ function ColumnResizeHandle({
883
+ columnId,
884
+ onResize,
885
+ onCommit,
886
+ onReset,
887
+ ariaLabel,
888
+ }: {
889
+ columnId: string
890
+ onResize: (columnId: string, width: number) => void
891
+ onCommit: () => void
892
+ onReset: (columnId: string) => void
893
+ ariaLabel: string
894
+ }) {
895
+ const [active, setActive] = React.useState(false)
896
+ // Holds the current drag's teardown so an unmount mid-drag (perspective apply,
897
+ // data reload, column-visibility change) still removes the document listeners
898
+ // and restores the body cursor/selection instead of leaking them.
899
+ const dragCleanupRef = React.useRef<(() => void) | null>(null)
900
+ React.useEffect(() => () => { dragCleanupRef.current?.() }, [])
901
+
902
+ const handlePointerDown = React.useCallback((event: React.PointerEvent<HTMLDivElement>) => {
903
+ if (event.button !== 0) return
904
+ event.preventDefault()
905
+ event.stopPropagation()
906
+ const headerCell = event.currentTarget.parentElement as HTMLElement | null
907
+ if (!headerCell) return
908
+ const startX = event.clientX
909
+ const startWidth = headerCell.getBoundingClientRect().width
910
+ setActive(true)
911
+ let frame = 0
912
+ let latest = startWidth
913
+ const onMove = (moveEvent: PointerEvent) => {
914
+ latest = Math.max(COLUMN_MIN_WIDTH, Math.min(COLUMN_MAX_WIDTH, Math.round(startWidth + (moveEvent.clientX - startX))))
915
+ if (frame) return
916
+ frame = window.requestAnimationFrame(() => {
917
+ frame = 0
918
+ onResize(columnId, latest)
919
+ })
920
+ }
921
+ const teardown = () => {
922
+ if (frame) window.cancelAnimationFrame(frame)
923
+ document.removeEventListener('pointermove', onMove)
924
+ document.removeEventListener('pointerup', onUp)
925
+ document.body.style.cursor = ''
926
+ document.body.style.userSelect = ''
927
+ dragCleanupRef.current = null
928
+ }
929
+ const onUp = () => {
930
+ onResize(columnId, latest)
931
+ teardown()
932
+ setActive(false)
933
+ onCommit()
934
+ }
935
+ dragCleanupRef.current = teardown
936
+ document.addEventListener('pointermove', onMove)
937
+ document.addEventListener('pointerup', onUp)
938
+ document.body.style.cursor = 'col-resize'
939
+ document.body.style.userSelect = 'none'
940
+ }, [columnId, onResize, onCommit])
941
+
942
+ return (
943
+ <div
944
+ role="separator"
945
+ aria-orientation="vertical"
946
+ aria-label={ariaLabel}
947
+ title={ariaLabel}
948
+ onPointerDown={handlePointerDown}
949
+ onClick={(event) => event.stopPropagation()}
950
+ onDoubleClick={(event) => { event.preventDefault(); event.stopPropagation(); onReset(columnId) }}
951
+ className="group/resize absolute right-0 top-0 z-10 flex h-full w-3 translate-x-1/2 cursor-col-resize touch-none select-none items-center justify-center"
952
+ >
953
+ {/* Always-visible short grip marks the column edge as resizable; it grows
954
+ to full height and brightens on header/handle hover and while dragging. */}
955
+ <span
956
+ aria-hidden
957
+ className={cn(
958
+ 'w-0.5 rounded-full transition-all',
959
+ active
960
+ ? 'h-full bg-primary'
961
+ : 'h-3.5 bg-border group-hover:h-full group-hover:bg-border group-hover/resize:h-full group-hover/resize:bg-primary',
962
+ )}
963
+ />
964
+ </div>
965
+ )
966
+ }
967
+
968
+ function SortableHeaderCell({ id, children, className, width }: { id: string; children: React.ReactNode; className?: string; width?: number }) {
860
969
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id })
861
970
  const isSticky = typeof className === 'string' && className.includes('sticky')
862
971
  const style: React.CSSProperties = {
@@ -865,6 +974,7 @@ function SortableHeaderCell({ id, children, className }: { id: string; children:
865
974
  opacity: isDragging ? 0.5 : 1,
866
975
  cursor: 'grab',
867
976
  position: isSticky ? 'sticky' : 'relative',
977
+ ...(typeof width === 'number' ? { width, minWidth: width, maxWidth: width } : {}),
868
978
  }
869
979
  return (
870
980
  <TableHead ref={setNodeRef} style={style} className={className} {...attributes} {...listeners}>
@@ -1106,6 +1216,11 @@ export function DataTable<T>({
1106
1216
  const [activePerspectiveId, setActivePerspectiveId] = React.useState<string | null>(initialActiveId)
1107
1217
  const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>(() => mergedInitialSettings?.columnVisibility ?? {})
1108
1218
  const [columnOrder, setColumnOrder] = React.useState<string[]>(() => mergedInitialSettings?.columnOrder ?? [])
1219
+ const [columnSizing, setColumnSizing] = React.useState<Record<string, number>>(() => mergedInitialSettings?.columnSizing ?? {})
1220
+ // Mirror of columnSizing so the resize commit (fired from a pointerup handler
1221
+ // set up on drag start) always reads the latest widths without a stale closure.
1222
+ const columnSizingRef = React.useRef(columnSizing)
1223
+ React.useEffect(() => { columnSizingRef.current = columnSizing }, [columnSizing])
1109
1224
  const [deletingIds, setDeletingIds] = React.useState<string[]>([])
1110
1225
  const [roleClearingIds, setRoleClearingIds] = React.useState<string[]>([])
1111
1226
  const [perspectiveApiMissing, setPerspectiveApiMissing] = React.useState(false)
@@ -1596,12 +1711,13 @@ export function DataTable<T>({
1596
1711
  const candidate: PerspectiveSettings = {
1597
1712
  columnOrder,
1598
1713
  columnVisibility: visibility,
1714
+ columnSizing,
1599
1715
  sorting,
1600
1716
  filters: filtersPayload,
1601
1717
  searchValue,
1602
1718
  }
1603
1719
  return sanitizePerspectiveSettings(candidate) ?? {}
1604
- }, [columnOrder, columnVisibility, sorting, filterValues, searchValue, advancedFilter])
1720
+ }, [columnOrder, columnVisibility, columnSizing, sorting, filterValues, searchValue, advancedFilter])
1605
1721
 
1606
1722
  const applyPerspectiveSettings = React.useCallback((
1607
1723
  settings: PerspectiveSettings,
@@ -1636,6 +1752,13 @@ export function DataTable<T>({
1636
1752
  setSorting([])
1637
1753
  onSortingChange?.([])
1638
1754
  }
1755
+ if (normalized.columnSizing) {
1756
+ setColumnSizing(normalized.columnSizing)
1757
+ columnSizingRef.current = normalized.columnSizing
1758
+ } else {
1759
+ setColumnSizing({})
1760
+ columnSizingRef.current = {}
1761
+ }
1639
1762
  // Two filter shapes can live in `settings.filters`:
1640
1763
  // 1. Persisted advanced-filter tree: `{ v: 2, root: {...} }`
1641
1764
  // 2. Legacy flat FilterValues record: arbitrary `{ key: value, ... }`
@@ -1672,6 +1795,17 @@ export function DataTable<T>({
1672
1795
  const snapshot: PerspectiveSnapshot = { perspectiveId: nextId, settings: normalized, updatedAt: Date.now() }
1673
1796
  writePerspectiveSnapshot(perspectiveTableId, snapshot)
1674
1797
  initialSnapshotRef.current = snapshot
1798
+ } else if (normalized.columnSizing && Object.keys(normalized.columnSizing).length) {
1799
+ // Preserve a "No view" snapshot that only carries user column widths so
1800
+ // they survive refresh without an active perspective (#1835). A genuine
1801
+ // "No view" clear passes empty settings (no columnSizing) and still clears.
1802
+ const snapshot: PerspectiveSnapshot = {
1803
+ perspectiveId: null,
1804
+ settings: { columnSizing: normalized.columnSizing },
1805
+ updatedAt: Date.now(),
1806
+ }
1807
+ writePerspectiveSnapshot(perspectiveTableId, snapshot)
1808
+ initialSnapshotRef.current = snapshot
1675
1809
  } else {
1676
1810
  writePerspectiveSnapshot(perspectiveTableId, null)
1677
1811
  initialSnapshotRef.current = null
@@ -1679,6 +1813,46 @@ export function DataTable<T>({
1679
1813
  }
1680
1814
  }, [onFiltersApply, onSearchChange, onSortingChange, perspectiveTableId, table, advancedFilter])
1681
1815
 
1816
+ // Persist the current column widths into the local snapshot so they survive a
1817
+ // refresh even without saving a perspective (#1835). Widths are merged into the
1818
+ // active perspective's other settings — resizing never disturbs the saved
1819
+ // column order/visibility/filters.
1820
+ const persistColumnSizingSnapshot = React.useCallback(() => {
1821
+ if (!perspectiveTableId) return
1822
+ const sizing = columnSizingRef.current
1823
+ const existing = readPerspectiveSnapshot(perspectiveTableId) ?? initialSnapshotRef.current
1824
+ const baseSettings: PerspectiveSettings = existing?.settings
1825
+ ? { ...existing.settings }
1826
+ : (mergedInitialSettings ? { ...mergedInitialSettings } : {})
1827
+ if (sizing && Object.keys(sizing).length) baseSettings.columnSizing = sizing
1828
+ else delete baseSettings.columnSizing
1829
+ const snapshot: PerspectiveSnapshot = {
1830
+ perspectiveId: existing?.perspectiveId ?? activePerspectiveId ?? null,
1831
+ settings: baseSettings,
1832
+ updatedAt: Date.now(),
1833
+ }
1834
+ writePerspectiveSnapshot(perspectiveTableId, snapshot)
1835
+ initialSnapshotRef.current = snapshot
1836
+ }, [perspectiveTableId, activePerspectiveId, mergedInitialSettings])
1837
+
1838
+ const handleColumnResize = React.useCallback((colId: string, width: number) => {
1839
+ const next = { ...columnSizingRef.current, [colId]: width }
1840
+ columnSizingRef.current = next
1841
+ setColumnSizing(next)
1842
+ }, [])
1843
+
1844
+ const commitColumnSizing = React.useCallback(() => {
1845
+ persistColumnSizingSnapshot()
1846
+ }, [persistColumnSizingSnapshot])
1847
+
1848
+ const resetColumnSize = React.useCallback((colId: string) => {
1849
+ const next = { ...columnSizingRef.current }
1850
+ delete next[colId]
1851
+ columnSizingRef.current = next
1852
+ setColumnSizing(next)
1853
+ persistColumnSizingSnapshot()
1854
+ }, [persistColumnSizingSnapshot])
1855
+
1682
1856
  React.useLayoutEffect(() => {
1683
1857
  if (!perspectiveTableId) return
1684
1858
  if (snapshotHydratedTableRef.current === perspectiveTableId) return
@@ -2012,6 +2186,10 @@ export function DataTable<T>({
2012
2186
 
2013
2187
  const dndSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }))
2014
2188
  const enableHeaderDnd = Boolean(columnChooser)
2189
+ // Column resize is offered only where widths can persist (#1835): tables with a
2190
+ // perspective config. Portal / settings / sub-tables that opt out of perspectives
2191
+ // get no handle, so resizing never silently resets on reload for them.
2192
+ const enableColumnResize = perspectiveEnabled
2015
2193
  const stableDndContextId = React.useMemo(
2016
2194
  () => sanitizeDndContextId(
2017
2195
  extensionTableId
@@ -2845,13 +3023,30 @@ export function DataTable<T>({
2845
3023
  ) : null}
2846
3024
  </Button>
2847
3025
  )
3026
+ const columnId = header.column.id
3027
+ const sizedWidth = enableColumnResize && columnId ? columnSizing[columnId] : undefined
3028
+ const resizeHandle = enableColumnResize && !header.isPlaceholder && columnId ? (
3029
+ <ColumnResizeHandle
3030
+ columnId={columnId}
3031
+ onResize={handleColumnResize}
3032
+ onCommit={commitColumnSizing}
3033
+ onReset={resetColumnSize}
3034
+ ariaLabel={t('ui.dataTable.resizeColumn', 'Resize column')}
3035
+ />
3036
+ ) : null
2848
3037
  return enableHeaderDnd ? (
2849
- <SortableHeaderCell key={header.id} id={header.id} className={responsiveClass(priority, columnMeta?.hidden) + stickyClass}>
3038
+ <SortableHeaderCell key={header.id} id={header.id} width={sizedWidth} className={cn('group', responsiveClass(priority, columnMeta?.hidden) + stickyClass)}>
2850
3039
  {headerCellContent}
3040
+ {resizeHandle}
2851
3041
  </SortableHeaderCell>
2852
3042
  ) : (
2853
- <TableHead key={header.id} className={responsiveClass(priority, columnMeta?.hidden) + stickyClass}>
3043
+ <TableHead
3044
+ key={header.id}
3045
+ className={cn('group relative', responsiveClass(priority, columnMeta?.hidden) + stickyClass)}
3046
+ style={typeof sizedWidth === 'number' ? { width: sizedWidth, minWidth: sizedWidth, maxWidth: sizedWidth } : undefined}
3047
+ >
2854
3048
  {headerCellContent}
3049
+ {resizeHandle}
2855
3050
  </TableHead>
2856
3051
  )
2857
3052
  })}
@@ -2974,14 +3169,22 @@ export function DataTable<T>({
2974
3169
  tooltipText = cellValue != null ? String(cellValue) : undefined
2975
3170
  }
2976
3171
 
3172
+ // A user-resized width (#1835) overrides the default truncation
3173
+ // max-width so the cell truncates at the dragged column width.
3174
+ const sizedWidth = enableColumnResize && columnId ? columnSizing[columnId] : undefined
3175
+ const effectiveMaxWidth = typeof sizedWidth === 'number' ? `${sizedWidth}px` : maxWidth
2977
3176
  const wrappedContent = shouldTruncate ? (
2978
- <TruncatedCell maxWidth={maxWidth} tooltipContent={tooltipText}>
3177
+ <TruncatedCell maxWidth={effectiveMaxWidth} tooltipContent={tooltipText}>
2979
3178
  {content}
2980
3179
  </TruncatedCell>
2981
3180
  ) : content
2982
3181
 
2983
3182
  return (
2984
- <TableCell key={cell.id} className={responsiveClass(priority, columnMeta?.hidden) + (isStickyCell ? ` md:sticky md:left-0 md:z-10 md:bg-background ${STICKY_LEFT_SHADOW_CLASS}` : '')}>
3183
+ <TableCell
3184
+ key={cell.id}
3185
+ className={responsiveClass(priority, columnMeta?.hidden) + (isStickyCell ? ` md:sticky md:left-0 md:z-10 md:bg-background ${STICKY_LEFT_SHADOW_CLASS}` : '')}
3186
+ style={typeof sizedWidth === 'number' ? { width: sizedWidth, minWidth: sizedWidth, maxWidth: sizedWidth } : undefined}
3187
+ >
2985
3188
  {wrappedContent}
2986
3189
  </TableCell>
2987
3190
  )
@@ -0,0 +1,49 @@
1
+ /** @jest-environment jsdom */
2
+ import { sanitizePerspectiveSettings } from '../DataTable'
3
+
4
+ // Column widths persisted in PerspectiveSettings come from untrusted sources
5
+ // (localStorage snapshot, saved perspectives API), so `sanitizePerspectiveSettings`
6
+ // must validate and clamp them before they drive layout (#1835).
7
+ describe('sanitizePerspectiveSettings — columnSizing (#1835)', () => {
8
+ it('keeps valid integer widths keyed by column id', () => {
9
+ const result = sanitizePerspectiveSettings({ columnSizing: { name: 240, status: 120 } })
10
+ expect(result?.columnSizing).toEqual({ name: 240, status: 120 })
11
+ })
12
+
13
+ it('clamps widths into the [60, 900] bound and rounds fractional pixels', () => {
14
+ const result = sanitizePerspectiveSettings({
15
+ columnSizing: { tooSmall: 10, tooBig: 5000, fractional: 199.7 },
16
+ })
17
+ expect(result?.columnSizing).toEqual({ tooSmall: 60, tooBig: 900, fractional: 200 })
18
+ })
19
+
20
+ it('drops non-numeric, non-finite, and prototype-polluting entries', () => {
21
+ // JSON.parse creates genuine own `__proto__` / `constructor` keys (unlike an
22
+ // object literal, where `__proto__` sets the prototype), so the forbidden-key
23
+ // guard is actually exercised.
24
+ const columnSizing = JSON.parse('{"good":150,"__proto__":300,"constructor":400}')
25
+ columnSizing.nan = Number.NaN
26
+ columnSizing.inf = Number.POSITIVE_INFINITY
27
+ columnSizing.str = '200'
28
+ const result = sanitizePerspectiveSettings({ columnSizing })
29
+ expect(result?.columnSizing).toEqual({ good: 150 })
30
+ })
31
+
32
+ it('omits columnSizing entirely when no valid entry survives', () => {
33
+ const result = sanitizePerspectiveSettings({ columnSizing: { bad: Number.NaN } })
34
+ expect(result?.columnSizing).toBeUndefined()
35
+ })
36
+
37
+ it('preserves columnSizing alongside the other settings', () => {
38
+ const result = sanitizePerspectiveSettings({
39
+ columnOrder: ['a', 'b'],
40
+ columnVisibility: { a: false },
41
+ columnSizing: { a: 300 },
42
+ })
43
+ expect(result).toEqual({
44
+ columnOrder: ['a', 'b'],
45
+ columnVisibility: { a: false },
46
+ columnSizing: { a: 300 },
47
+ })
48
+ })
49
+ })
@@ -17,6 +17,16 @@ describe('DataTable perspective snapshot storage (versioned envelope)', () => {
17
17
  expect(readPerspectiveSnapshot('tbl')).toEqual(snapshot)
18
18
  })
19
19
 
20
+ it('round-trips user column widths (columnSizing) through the snapshot (#1835)', () => {
21
+ const snapshot = {
22
+ perspectiveId: 'p1',
23
+ settings: { columnOrder: ['a', 'b'], columnSizing: { a: 240, b: 120 } },
24
+ updatedAt: 456,
25
+ }
26
+ writePerspectiveSnapshot('tbl-sizing', snapshot)
27
+ expect(readPerspectiveSnapshot('tbl-sizing')).toEqual(snapshot)
28
+ })
29
+
20
30
  it('migrates a legacy bare (pre-envelope) snapshot on read', () => {
21
31
  const legacy = { perspectiveId: 'p2', settings: { columnOrder: ['x'] }, updatedAt: 7 }
22
32
  localStorage.setItem(`${PREFIX}:tbl2`, JSON.stringify(legacy))