{"version":3,"file":"useColumnResize.mjs","names":[],"sources":["../../../../package/common/virtualList/useColumnResize.ts"],"sourcesContent":["import React, { useCallback, useLayoutEffect, useRef, useState } from 'react';\n\nimport {\n  columnCellSelector,\n  columnWidthVar,\n  loadPersistedColumns,\n  resolveFrozenWidth,\n  MIN_COLUMN_WIDTH,\n} from './virtualList.utils';\nimport type { VirtualListColumn } from './virtualList.types';\n\ninterface UseColumnResizeArgs<T> {\n  columns?: VirtualListColumn<T>[];\n  /**\n   * The columns currently rendered, in order. A ref rather than a value because\n   * the visible set is derived one hook later, and only the freeze reads it, at\n   * event time.\n   */\n  visibleColumnsRef: React.RefObject<VirtualListColumn<T>[]>;\n  resizableColumns: boolean;\n  /** Controlled widths. When provided, internal state is ignored. */\n  columnWidths?: Record<string, number>;\n  onColumnWidthsChange?: (widths: Record<string, number>) => void;\n  /** Restores widths saved under this key. Uncontrolled mode only. */\n  persistColumnsKey?: string;\n  rootRef: React.RefObject<HTMLDivElement | null>;\n}\n\nexport interface UseColumnResizeResult<T> {\n  widths: Record<string, number>;\n  /**\n   * True once every visible column has an exact px width. Nothing moves at that\n   * moment, but from then on the layout is pixels rather than `fr`/`fit`.\n   */\n  isFrozen: boolean;\n  isResizing: boolean;\n  /** Drops every user width and hands the layout back to the column definitions. */\n  reset: () => void;\n  /** Whether this column takes a resize at all, by handle or by keyboard. */\n  isResizable: (col: VirtualListColumn<T>) => boolean;\n  /** Widens (or, negative, narrows) one column. The keyboard entry point. */\n  resizeBy: (key: string, delta: number) => void;\n  /** Takes one column down to its minimum. */\n  resizeToMin: (key: string) => void;\n  /** Sizes one column to its widest rendered cell. */\n  autoFit: (key: string) => void;\n  /** Pointer props for one column's handle, or null when that column can't be resized. */\n  getHandleProps: (col: VirtualListColumn<T>) => React.HTMLAttributes<HTMLDivElement> | null;\n}\n\nfunction minWidthFor<T>(col: VirtualListColumn<T> | undefined): number {\n  return Math.max(1, col?.minWidth ?? MIN_COLUMN_WIDTH);\n}\n\n/**\n * User column resizing: the width state, and a drag that doesn't go through\n * React.\n *\n * A pointer move writes the dragged column's custom property straight onto the\n * list element. The header and every row read that same property, so one DOM\n * write resizes all of them in a single layout pass. Going through state\n * instead would re-render the header plus every visible row on every frame, and\n * the handle would trail the cursor by one frame, which is the whole difference\n * between this feeling attached to the pointer and feeling cheap. React is told\n * once, on release.\n *\n * The first resize **freezes** every visible column to its measured width. A\n * `fr` track can't express \"this one column is now 240px\" without the others\n * silently absorbing the difference, and once they hit their minimum the dragged\n * edge stops following the cursor, which is the worst moment for it to happen.\n * Frozen, the table is exact pixels and simply scrolls horizontally instead.\n */\nexport function useColumnResize<T>({\n  columns,\n  visibleColumnsRef,\n  resizableColumns,\n  columnWidths,\n  onColumnWidthsChange,\n  persistColumnsKey,\n  rootRef,\n}: UseColumnResizeArgs<T>): UseColumnResizeResult<T> {\n  const isControlled = columnWidths !== undefined;\n  const [internalWidths, setInternalWidths] = useState<Record<string, number>>(\n    () => loadPersistedColumns(persistColumnsKey)?.widths ?? {},\n  );\n  const [isResizing, setIsResizing] = useState(false);\n  const effectiveWidths = isControlled ? columnWidths! : internalWidths;\n\n  // Read by the pointer handlers, which are installed once per drag and must not\n  // capture a stale snapshot of either.\n  const widthsRef = useRef(effectiveWidths);\n  widthsRef.current = effectiveWidths;\n\n  const commit = useCallback(\n    (next: Record<string, number>) => {\n      if (!isControlled) setInternalWidths(next);\n      onColumnWidthsChange?.(next);\n    },\n    [isControlled, onColumnWidthsChange],\n  );\n\n  /**\n   * Current width of every visible column, measured from the rendered header so\n   * the freeze is invisible: the px values are the ones already on screen.\n   * Columns that already have a width keep it, and a header cell that can't be\n   * measured (jsdom, a display:none list) falls back to its declared width.\n   */\n  const freeze = useCallback((): Record<string, number> => {\n    const root = rootRef.current;\n    const next: Record<string, number> = { ...widthsRef.current };\n    for (const col of visibleColumnsRef.current) {\n      if (next[col.key] != null) continue;\n      const cell = root?.querySelector(`[aria-rowindex=\"1\"] ${columnCellSelector(col.key)}`);\n      const measured = cell ? Math.round(cell.getBoundingClientRect().width) : 0;\n      next[col.key] = measured > 0 ? measured : resolveFrozenWidth(col);\n    }\n    return next;\n  }, [rootRef, visibleColumnsRef]);\n\n  // The widths live on the element as custom properties, written from here and\n  // from the drag, never through React's `style`. One owner means a drag's\n  // imperative writes can't be clobbered by a render that happens to land\n  // mid-gesture, and the steady state still follows the committed widths.\n  useLayoutEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    for (const [key, width] of Object.entries(effectiveWidths))\n      root.style.setProperty(columnWidthVar(key), `${width}px`);\n  }, [effectiveWidths, rootRef]);\n\n  const applyWidth = useCallback(\n    (key: string, width: number) => {\n      rootRef.current?.style.setProperty(columnWidthVar(key), `${width}px`);\n    },\n    [rootRef],\n  );\n\n  /**\n   * In controlled mode the drag has already written widths the prop may not\n   * agree with. A frame later either the consumer has accepted them (the ref\n   * holds the new values and this writes them again, a no-op) or it hasn't, and\n   * the columns snap back to what the prop says. Without it a rejected width\n   * would stay on screen, since nothing re-rendered to correct it.\n   */\n  const syncControlled = useCallback(() => {\n    if (!isControlled) return;\n    requestAnimationFrame(() => {\n      for (const [key, width] of Object.entries(widthsRef.current)) applyWidth(key, width);\n    });\n  }, [applyWidth, isControlled]);\n\n  const resize = useCallback(\n    (key: string, toWidth: (from: number) => number) => {\n      const base = freeze();\n      const min = minWidthFor(columns?.find((c) => c.key === key));\n      const next = { ...base, [key]: Math.max(min, Math.round(toWidth(base[key]))) };\n      applyWidth(key, next[key]);\n      commit(next);\n      syncControlled();\n    },\n    [applyWidth, columns, commit, freeze, syncControlled],\n  );\n\n  const beginDrag = useCallback(\n    (event: React.PointerEvent<HTMLDivElement>, key: string) => {\n      if (event.button !== 0) return;\n      // The handle sits on top of the header's sort button; without this the\n      // drag would also count as a click on it.\n      event.preventDefault();\n      event.stopPropagation();\n\n      const handle = event.currentTarget;\n      const base = freeze();\n      const startWidth = base[key];\n      const startX = event.clientX;\n      const min = minWidthFor(columns?.find((c) => c.key === key));\n\n      let width = startWidth;\n      let frame = 0;\n      const paint = () => {\n        frame = 0;\n        applyWidth(key, width);\n      };\n\n      function finish(commitWidth: number | null) {\n        if (frame) cancelAnimationFrame(frame);\n        handle.releasePointerCapture?.(event.pointerId);\n        handle.removeEventListener('pointermove', onMove);\n        handle.removeEventListener('pointerup', onUp);\n        handle.removeEventListener('pointercancel', onCancel);\n        handle.removeEventListener('lostpointercapture', onCancel);\n        window.removeEventListener('keydown', onKeyDown, true);\n        document.body.style.cursor = '';\n        document.body.style.userSelect = '';\n        setIsResizing(false);\n        if (commitWidth == null) {\n          // The freeze was committed at pointerdown and stays: on its own it\n          // changes nothing the user can see. Only the drag is undone.\n          applyWidth(key, startWidth);\n          return;\n        }\n        if (commitWidth !== startWidth) commit({ ...base, [key]: commitWidth });\n        syncControlled();\n      }\n\n      function onMove(moveEvent: PointerEvent) {\n        // Always from the values captured at pointerdown, never from the\n        // element's current geometry: re-measuring mid-drag lets rounding\n        // accumulate and the handle slides away from the cursor.\n        width = Math.max(min, Math.round(startWidth + moveEvent.clientX - startX));\n        if (!frame) frame = requestAnimationFrame(paint);\n      }\n      function onUp() {\n        finish(width);\n      }\n      function onCancel() {\n        finish(null);\n      }\n      function onKeyDown(keyEvent: KeyboardEvent) {\n        if (keyEvent.key !== 'Escape') return;\n        keyEvent.preventDefault();\n        finish(null);\n      }\n\n      handle.setPointerCapture?.(event.pointerId);\n      handle.addEventListener('pointermove', onMove);\n      handle.addEventListener('pointerup', onUp);\n      handle.addEventListener('pointercancel', onCancel);\n      handle.addEventListener('lostpointercapture', onCancel);\n      window.addEventListener('keydown', onKeyDown, true);\n      // On the body, not on the handle: the pointer leaves the handle as soon as\n      // the drag starts, and the cursor must not flicker back to the default.\n      document.body.style.cursor = 'col-resize';\n      document.body.style.userSelect = 'none';\n      setIsResizing(true);\n      // Committing the freeze up front, rather than on release: until React has\n      // rendered the frozen template, the tracks are still `fr`/`fit` and read\n      // no custom property, so the drag would move nothing at all.\n      if (Object.keys(widthsRef.current).length === 0) commit(base);\n    },\n    [applyWidth, columns, commit, freeze, syncControlled],\n  );\n\n  /**\n   * Width the column would need for its widest cell, measured by sizing its\n   * track to `max-content` for one synchronous layout.\n   *\n   * Reading `scrollWidth` off the cells instead reports the *min*-content width:\n   * a body cell is free to wrap, so long text never overflows it, it only makes\n   * the row taller, and the widest thing that can't wrap is a single word.\n   * `max-content` is the width with no wrapping at all, which is what fitting a\n   * column means. The header and every row are separate grids, so each sizes its\n   * own track independently and all of them have to be read: the widest is the\n   * one width that fits the whole column.\n   */\n  const measureAutoFit = useCallback(\n    (key: string): number => {\n      const root = rootRef.current;\n      if (!root) return 0;\n      const widthProperty = columnWidthVar(key);\n      const previousWidth = root.style.getPropertyValue(widthProperty);\n      // `--vl-total` is a calc() over these same properties, so it turns invalid\n      // while one of them holds a keyword, and the header and body would be left\n      // with an unresolvable width mid-measurement.\n      const previousTotal = root.style.getPropertyValue('--vl-total');\n\n      root.style.setProperty(widthProperty, 'max-content');\n      root.style.removeProperty('--vl-total');\n      let measured = 0;\n      root.querySelectorAll(columnCellSelector(key)).forEach((cell) => {\n        const width = Math.ceil(cell.getBoundingClientRect().width);\n        if (width > measured) measured = width;\n      });\n      root.style.setProperty(widthProperty, previousWidth);\n      if (previousTotal) root.style.setProperty('--vl-total', previousTotal);\n\n      // Only the rendered rows are measurable, so this fits what is on screen.\n      return Math.min(measured, root.clientWidth);\n    },\n    [rootRef],\n  );\n\n  const autoFit = useCallback(\n    (key: string) => {\n      const run = () => {\n        const measured = measureAutoFit(key);\n        if (measured > 0) resize(key, () => measured);\n      };\n      // Until the frozen template has rendered, the tracks read no custom\n      // property and sizing one to `max-content` measures nothing. A drag has\n      // already frozen by the time its double-click lands; a keyboard `End` on\n      // an untouched list has not, so it waits for the render it just asked for.\n      if (Object.keys(widthsRef.current).length > 0) run();\n      else {\n        commit(freeze());\n        requestAnimationFrame(run);\n      }\n    },\n    [commit, freeze, measureAutoFit, resize],\n  );\n\n  const reset = useCallback(() => {\n    const root = rootRef.current;\n    // The layout effect only ever writes the widths it has, so the properties of\n    // the ones being dropped have to be cleared here or they would linger and\n    // reappear with the next freeze.\n    if (root) for (const key of Object.keys(widthsRef.current)) root.style.removeProperty(columnWidthVar(key));\n    commit({});\n  }, [commit, rootRef]);\n\n  const isResizable = useCallback(\n    (col: VirtualListColumn<T>) => resizableColumns && col.resizable !== false,\n    [resizableColumns],\n  );\n\n  const resizeBy = useCallback((key: string, delta: number) => resize(key, (w) => w + delta), [resize]);\n\n  const resizeToMin = useCallback(\n    (key: string) => resize(key, () => minWidthFor(columns?.find((c) => c.key === key))),\n    [columns, resize],\n  );\n\n  const getHandleProps = useCallback(\n    (col: VirtualListColumn<T>): React.HTMLAttributes<HTMLDivElement> | null => {\n      if (!isResizable(col)) return null;\n      return {\n        // Deliberately not focusable, and hidden from assistive technology: the\n        // keyboard path is Alt plus the arrow keys on the header cell itself, so\n        // a second tab stop per column would buy nothing and cost a lot.\n        'aria-hidden': true,\n        onPointerDown: (event: React.PointerEvent<HTMLDivElement>) => beginDrag(event, col.key),\n        onDoubleClick: (event: React.MouseEvent) => {\n          event.stopPropagation();\n          autoFit(col.key);\n        },\n        onClick: (event: React.MouseEvent) => event.stopPropagation(),\n      };\n    },\n    [autoFit, beginDrag, isResizable],\n  );\n\n  return {\n    widths: effectiveWidths,\n    isFrozen: Object.keys(effectiveWidths).length > 0,\n    isResizing,\n    reset,\n    isResizable,\n    resizeBy,\n    resizeToMin,\n    autoFit,\n    getHandleProps,\n  };\n}\n"],"mappings":"mOAkDA,SAAS,EAAe,EAA+C,CACrE,OAAO,KAAK,IAAI,EAAG,GAAK,UAAA,EAA4B,CACtD,CAoBA,SAAgB,EAAmB,CACjC,UACA,oBACA,mBACA,eACA,uBACA,oBACA,WACmD,CACnD,IAAM,EAAe,IAAiB,IAAA,GAChC,CAAC,EAAgB,GAAqB,MACpC,EAAqB,CAAiB,CAAC,EAAE,QAAU,CAAC,CAC5D,EACM,CAAC,EAAY,GAAiB,EAAS,EAAK,EAC5C,EAAkB,EAAe,EAAgB,EAIjD,EAAY,EAAO,CAAe,EACxC,EAAU,QAAU,EAEpB,IAAM,EAAS,EACZ,GAAiC,CAC3B,GAAc,EAAkB,CAAI,EACzC,IAAuB,CAAI,CAC7B,EACA,CAAC,EAAc,CAAoB,CACrC,EAQM,EAAS,MAA0C,CACvD,IAAM,EAAO,EAAQ,QACf,EAA+B,CAAE,GAAG,EAAU,OAAQ,EAC5D,IAAK,IAAM,KAAO,EAAkB,QAAS,CAC3C,GAAI,EAAK,EAAI,MAAQ,KAAM,SAC3B,IAAM,EAAO,GAAM,cAAc,uBAAuB,EAAmB,EAAI,GAAG,GAAG,EAC/E,EAAW,EAAO,KAAK,MAAM,EAAK,sBAAsB,CAAC,CAAC,KAAK,EAAI,EACzE,EAAK,EAAI,KAAO,EAAW,EAAI,EAAW,EAAmB,CAAG,CAClE,CACA,OAAO,CACT,EAAG,CAAC,EAAS,CAAiB,CAAC,EAM/B,MAAsB,CACpB,IAAM,EAAO,EAAQ,QAChB,KACL,IAAK,GAAM,CAAC,EAAK,KAAU,OAAO,QAAQ,CAAe,EACvD,EAAK,MAAM,YAAY,EAAe,CAAG,EAAG,GAAG,EAAM,GAAG,CAC5D,EAAG,CAAC,EAAiB,CAAO,CAAC,EAE7B,IAAM,EAAa,GAChB,EAAa,IAAkB,CAC9B,EAAQ,SAAS,MAAM,YAAY,EAAe,CAAG,EAAG,GAAG,EAAM,GAAG,CACtE,EACA,CAAC,CAAO,CACV,EASM,EAAiB,MAAkB,CAClC,GACL,0BAA4B,CAC1B,IAAK,GAAM,CAAC,EAAK,KAAU,OAAO,QAAQ,EAAU,OAAO,EAAG,EAAW,EAAK,CAAK,CACrF,CAAC,CACH,EAAG,CAAC,EAAY,CAAY,CAAC,EAEvB,EAAS,GACZ,EAAa,IAAsC,CAClD,IAAM,EAAO,EAAO,EACd,EAAM,EAAY,GAAS,KAAM,GAAM,EAAE,MAAQ,CAAG,CAAC,EACrD,EAAO,CAAE,GAAG,GAAO,GAAM,KAAK,IAAI,EAAK,KAAK,MAAM,EAAQ,EAAK,EAAI,CAAC,CAAC,CAAE,EAC7E,EAAW,EAAK,EAAK,EAAI,EACzB,EAAO,CAAI,EACX,EAAe,CACjB,EACA,CAAC,EAAY,EAAS,EAAQ,EAAQ,CAAc,CACtD,EAEM,EAAY,GACf,EAA2C,IAAgB,CAC1D,GAAI,EAAM,SAAW,EAAG,OAGxB,EAAM,eAAe,EACrB,EAAM,gBAAgB,EAEtB,IAAM,EAAS,EAAM,cACf,EAAO,EAAO,EACd,EAAa,EAAK,GAClB,EAAS,EAAM,QACf,EAAM,EAAY,GAAS,KAAM,GAAM,EAAE,MAAQ,CAAG,CAAC,EAEvD,EAAQ,EACR,EAAQ,EACN,MAAc,CAClB,EAAQ,EACR,EAAW,EAAK,CAAK,CACvB,EAEA,SAAS,EAAO,EAA4B,CAW1C,GAVI,GAAO,qBAAqB,CAAK,EACrC,EAAO,wBAAwB,EAAM,SAAS,EAC9C,EAAO,oBAAoB,cAAe,CAAM,EAChD,EAAO,oBAAoB,YAAa,CAAI,EAC5C,EAAO,oBAAoB,gBAAiB,CAAQ,EACpD,EAAO,oBAAoB,qBAAsB,CAAQ,EACzD,OAAO,oBAAoB,UAAW,EAAW,EAAI,EACrD,SAAS,KAAK,MAAM,OAAS,GAC7B,SAAS,KAAK,MAAM,WAAa,GACjC,EAAc,EAAK,EACf,GAAe,KAAM,CAGvB,EAAW,EAAK,CAAU,EAC1B,MACF,CACI,IAAgB,GAAY,EAAO,CAAE,GAAG,GAAO,GAAM,CAAY,CAAC,EACtE,EAAe,CACjB,CAEA,SAAS,EAAO,EAAyB,CAIvC,EAAQ,KAAK,IAAI,EAAK,KAAK,MAAM,EAAa,EAAU,QAAU,CAAM,CAAC,EACzE,AAAY,IAAQ,sBAAsB,CAAK,CACjD,CACA,SAAS,GAAO,CACd,EAAO,CAAK,CACd,CACA,SAAS,GAAW,CAClB,EAAO,IAAI,CACb,CACA,SAAS,EAAU,EAAyB,CACtC,EAAS,MAAQ,WACrB,EAAS,eAAe,EACxB,EAAO,IAAI,EACb,CAEA,EAAO,oBAAoB,EAAM,SAAS,EAC1C,EAAO,iBAAiB,cAAe,CAAM,EAC7C,EAAO,iBAAiB,YAAa,CAAI,EACzC,EAAO,iBAAiB,gBAAiB,CAAQ,EACjD,EAAO,iBAAiB,qBAAsB,CAAQ,EACtD,OAAO,iBAAiB,UAAW,EAAW,EAAI,EAGlD,SAAS,KAAK,MAAM,OAAS,aAC7B,SAAS,KAAK,MAAM,WAAa,OACjC,EAAc,EAAI,EAId,OAAO,KAAK,EAAU,OAAO,CAAC,CAAC,SAAW,GAAG,EAAO,CAAI,CAC9D,EACA,CAAC,EAAY,EAAS,EAAQ,EAAQ,CAAc,CACtD,EAcM,EAAiB,EACpB,GAAwB,CACvB,IAAM,EAAO,EAAQ,QACrB,GAAI,CAAC,EAAM,MAAO,GAClB,IAAM,EAAgB,EAAe,CAAG,EAClC,EAAgB,EAAK,MAAM,iBAAiB,CAAa,EAIzD,EAAgB,EAAK,MAAM,iBAAiB,YAAY,EAE9D,EAAK,MAAM,YAAY,EAAe,aAAa,EACnD,EAAK,MAAM,eAAe,YAAY,EACtC,IAAI,EAAW,EASf,OARA,EAAK,iBAAiB,EAAmB,CAAG,CAAC,CAAC,CAAC,QAAS,GAAS,CAC/D,IAAM,EAAQ,KAAK,KAAK,EAAK,sBAAsB,CAAC,CAAC,KAAK,EACtD,EAAQ,IAAU,EAAW,EACnC,CAAC,EACD,EAAK,MAAM,YAAY,EAAe,CAAa,EAC/C,GAAe,EAAK,MAAM,YAAY,aAAc,CAAa,EAG9D,KAAK,IAAI,EAAU,EAAK,WAAW,CAC5C,EACA,CAAC,CAAO,CACV,EAEM,EAAU,EACb,GAAgB,CACf,IAAM,MAAY,CAChB,IAAM,EAAW,EAAe,CAAG,EAC/B,EAAW,GAAG,EAAO,MAAW,CAAQ,CAC9C,EAKI,OAAO,KAAK,EAAU,OAAO,CAAC,CAAC,OAAS,EAAG,EAAI,GAEjD,EAAO,EAAO,CAAC,EACf,sBAAsB,CAAG,EAE7B,EACA,CAAC,EAAQ,EAAQ,EAAgB,CAAM,CACzC,EAEM,EAAQ,MAAkB,CAC9B,IAAM,EAAO,EAAQ,QAIrB,GAAI,EAAM,IAAK,IAAM,KAAO,OAAO,KAAK,EAAU,OAAO,EAAG,EAAK,MAAM,eAAe,EAAe,CAAG,CAAC,EACzG,EAAO,CAAC,CAAC,CACX,EAAG,CAAC,EAAQ,CAAO,CAAC,EAEd,EAAc,EACjB,GAA8B,GAAoB,EAAI,YAAc,GACrE,CAAC,CAAgB,CACnB,EAEM,EAAW,GAAa,EAAa,IAAkB,EAAO,EAAM,GAAM,EAAI,CAAK,EAAG,CAAC,CAAM,CAAC,EAE9F,EAAc,EACjB,GAAgB,EAAO,MAAW,EAAY,GAAS,KAAM,GAAM,EAAE,MAAQ,CAAG,CAAC,CAAC,EACnF,CAAC,EAAS,CAAM,CAClB,EAEM,EAAiB,EACpB,GACM,EAAY,CAAG,EACb,CAIL,cAAe,GACf,cAAgB,GAA8C,EAAU,EAAO,EAAI,GAAG,EACtF,cAAgB,GAA4B,CAC1C,EAAM,gBAAgB,EACtB,EAAQ,EAAI,GAAG,CACjB,EACA,QAAU,GAA4B,EAAM,gBAAgB,CAC9D,EAZ8B,KAchC,CAAC,EAAS,EAAW,CAAW,CAClC,EAEA,MAAO,CACL,OAAQ,EACR,SAAU,OAAO,KAAK,CAAe,CAAC,CAAC,OAAS,EAChD,aACA,QACA,cACA,WACA,cACA,UACA,gBACF,CACF"}