@@ -10,7 +10,12 @@ import {
1010 useRef ,
1111 useState ,
1212} from "react" ;
13- import type { KeyboardEvent , MouseEvent , ReactNode } from "react" ;
13+ import type {
14+ KeyboardEvent ,
15+ MouseEvent ,
16+ PointerEvent as ReactPointerEvent ,
17+ ReactNode ,
18+ } from "react" ;
1419
1520import { useControllableState } from "../hooks/use-controllable-state.js" ;
1621import { useCopy } from "../hooks/use-copy.js" ;
@@ -52,6 +57,7 @@ export type CsvTableLabels = {
5257 readonly selectAll : string ;
5358 readonly nameBox : string ;
5459 readonly cellContents : string ;
60+ readonly resizeHint : string ;
5561 readonly count : ( rows : number , columns : number ) => string ;
5662 readonly filteredCount : (
5763 matching : number ,
@@ -87,6 +93,8 @@ const LABELS: CsvTableLabels = {
8793 selectAll : "Select all" ,
8894 nameBox : "Selected cell" ,
8995 cellContents : "Cell contents" ,
96+ resizeHint :
97+ "Alt+Left and Alt+Right resize the column, Alt+Enter fits it to its contents" ,
9098 count : ( rows , columns ) =>
9199 `${ counted ( rows , "row" , "rows" ) } · ${ counted ( columns , "column" , "columns" ) } ` ,
92100 filteredCount : ( matching , total , columns ) =>
@@ -319,6 +327,26 @@ type Range = {
319327 /** Picked by row numbers or column letters, and addressed as such: `3:5`, `B:D`. */
320328 readonly whole ?: "rows" | "columns" ;
321329} ;
330+ type TextStyle = {
331+ readonly font : string ;
332+ /** One figure's width with tabular figures on. */
333+ readonly figure : number ;
334+ readonly padding : number ;
335+ } ;
336+ type Metrics = {
337+ readonly head : TextStyle ;
338+ readonly cell : TextStyle ;
339+ readonly gutter : TextStyle ;
340+ /** The sort icon and its gap beside a header's text. */
341+ readonly headChrome : number ;
342+ } ;
343+
344+ const COLUMN_MIN_PX = 32 ;
345+ /** The widest a column starts; a long note column would push the rest off-screen. */
346+ const COLUMN_START_MAX_PX = 320 ;
347+ const COLUMN_MAX_PX = 960 ;
348+ const RESIZE_STEP_PX = 16 ;
349+ const RESIZE_KEYS = "Alt+ArrowLeft Alt+ArrowRight Alt+Enter" ;
322350
323351const clamp = ( value : number , min : number , max : number ) =>
324352 Math . max ( min , Math . min ( max , value ) ) ;
@@ -344,6 +372,59 @@ const keepTextUnselected = (event: MouseEvent) => {
344372 if ( event . shiftKey ) event . preventDefault ( ) ;
345373} ;
346374
375+ const padding = ( style : CSSStyleDeclaration ) =>
376+ Number . parseFloat ( style . paddingLeft ) + Number . parseFloat ( style . paddingRight ) ;
377+
378+ // Built from the longhands: the computed `font` shorthand is empty in some
379+ // engines.
380+ const fontOf = ( style : CSSStyleDeclaration ) =>
381+ `${ style . fontStyle } ${ style . fontWeight } ${ style . fontSize } ${ style . fontFamily } ` ;
382+
383+ let canvas : CanvasRenderingContext2D | null | undefined ;
384+ const textContext = ( ) =>
385+ canvas === undefined
386+ ? ( canvas = document . createElement ( "canvas" ) . getContext ( "2d" ) )
387+ : canvas ;
388+
389+ /** Canvas has no tabular figures, so one figure is measured in the DOM. */
390+ function tabularFigure ( font : string ) {
391+ const probe = document . createElement ( "span" ) ;
392+ probe . style . cssText =
393+ "position:absolute;visibility:hidden;white-space:pre;font-variant-numeric:tabular-nums" ;
394+ probe . style . font = font ;
395+ probe . textContent = "0123456789" ;
396+ document . body . append ( probe ) ;
397+ const width = probe . getBoundingClientRect ( ) . width / 10 ;
398+ probe . remove ( ) ;
399+ return width ;
400+ }
401+
402+ function textStyle ( element : Element ) : TextStyle {
403+ const style = getComputedStyle ( element ) ;
404+ const font = fontOf ( style ) ;
405+ return { font, figure : tabularFigure ( font ) , padding : padding ( style ) } ;
406+ }
407+
408+ const FIGURES = / \d / g;
409+
410+ function textWidth (
411+ context : CanvasRenderingContext2D ,
412+ style : TextStyle ,
413+ text : string ,
414+ tabular : boolean ,
415+ ) {
416+ context . font = style . font ;
417+ if ( ! tabular ) return context . measureText ( text ) . width ;
418+ const figures = text . match ( FIGURES ) ?. length ?? 0 ;
419+ return (
420+ context . measureText ( text . replace ( FIGURES , "" ) ) . width +
421+ figures * style . figure
422+ ) ;
423+ }
424+
425+ // A pixel for the cell's border, and one for sub-pixel text rounding.
426+ const fitted = ( width : number ) => Math . ceil ( width ) + 2 ;
427+
347428/**
348429 * Selection positions index the sorted, filtered view, so a copy is what the
349430 * person sees, and it spans every matching row, including those past the
@@ -383,6 +464,8 @@ function CsvGrid({
383464 } ) ;
384465 const [ range , setRange ] = useState < Range | null > ( null ) ;
385466 const [ active , setActive ] = useState < Cell > ( { row : - 1 , column : 0 } ) ;
467+ const [ widths , setWidths ] = useState < readonly number [ ] | null > ( null ) ;
468+ const [ metrics , setMetrics ] = useState < Metrics | null > ( null ) ;
386469 const [ failedCopy , setFailedCopy ] = useState < Range | null > ( null ) ;
387470 const { copied, copy } = useCopy ( ) ;
388471 const deferredQuery = useDeferredValue ( query ) ;
@@ -481,6 +564,58 @@ function CsvGrid({
481564 [ ] ,
482565 ) ;
483566
567+ // Columns start at their content's width, measured as text rather than read
568+ // from a first layout a narrow container would already have squeezed.
569+ // Fixed widths then let a resize, a sort or a filter re-lay one <col>, not
570+ // every cell. Without canvas text metrics the grid stays on automatic layout.
571+ useLayoutEffect ( ( ) => {
572+ const head = scroller . current ?. querySelector ( '[data-cell="-1:0"]' ) ;
573+ const gutter = scroller . current ?. querySelector ( '[data-cell="-1:-1"]' ) ;
574+ const context = textContext ( ) ;
575+ if ( ! head || ! gutter || context === null ) return ;
576+ const headStyle = getComputedStyle ( head ) ;
577+ const measured : Metrics = {
578+ head : textStyle ( head ) ,
579+ cell : textStyle (
580+ scroller . current ?. querySelector ( '[data-cell="0:0"]' ) ?? gutter ,
581+ ) ,
582+ // Selected row numbers are semibold, so the column fits the heavier weight.
583+ gutter : { ...textStyle ( gutter ) , font : fontOf ( headStyle ) } ,
584+ headChrome :
585+ ( head . querySelector ( "svg" ) ?. getBoundingClientRect ( ) . width ?? 0 ) +
586+ Number . parseFloat ( headStyle . columnGap || "0" ) ,
587+ } ;
588+ setMetrics ( measured ) ;
589+ setWidths (
590+ ( current ) =>
591+ current ??
592+ sheet . columns . map ( ( _ , column ) =>
593+ clamp (
594+ columnWidth ( context , measured , column ) ,
595+ COLUMN_MIN_PX ,
596+ COLUMN_START_MAX_PX ,
597+ ) ,
598+ ) ,
599+ ) ;
600+ } , [ ] ) ;
601+
602+ const context = metrics === null ? null : textContext ( ) ;
603+ const gutterWidth =
604+ metrics === null || context === null
605+ ? null
606+ : fitted (
607+ textWidth (
608+ context ,
609+ metrics . gutter ,
610+ ( order . length + 1 ) . toLocaleString ( ) ,
611+ true ,
612+ ) + metrics . gutter . padding ,
613+ ) ;
614+ const dataWidth =
615+ widths === null || gutterWidth === null
616+ ? null
617+ : gutterWidth + widths . reduce ( ( sum , width ) => sum + width , 0 ) ;
618+
484619 // A filter that drops the focused row moves focus to the nearest row left,
485620 // in sheet order, rather than losing it to the page.
486621 const shownBefore = useRef ( view ) ;
@@ -530,7 +665,76 @@ function CsvGrid({
530665 box . style . top = `${ start . top - origin . top } px` ;
531666 box . style . width = `${ end . right - start . left } px` ;
532667 box . style . height = `${ end . bottom - start . top } px` ;
533- } , [ rowFrom , rowTo , columnFrom , columnTo , lastShown , shown ] ) ;
668+ } , [ rowFrom , rowTo , columnFrom , columnTo , lastShown , widths , shown ] ) ;
669+
670+ const resizeColumn = useCallback ( ( column : number , width : number ) => {
671+ setWidths (
672+ ( current ) =>
673+ current &&
674+ current . map ( ( previous , index ) =>
675+ index === column
676+ ? clamp ( Math . round ( width ) , COLUMN_MIN_PX , COLUMN_MAX_PX )
677+ : previous ,
678+ ) ,
679+ ) ;
680+ } , [ ] ) ;
681+
682+ /** A column's content width: its header and every rendered cell. */
683+ function columnWidth (
684+ context : CanvasRenderingContext2D ,
685+ measured : Metrics ,
686+ column : number ,
687+ ) {
688+ const numeric = sheet . columns [ column ] ?. numeric ?? false ;
689+ let widest =
690+ textWidth (
691+ context ,
692+ measured . head ,
693+ columnTitle ( sheet , column , wording ) ,
694+ false ,
695+ ) +
696+ measured . headChrome +
697+ measured . head . padding ;
698+ for ( const index of shown ) {
699+ widest = Math . max (
700+ widest ,
701+ textWidth (
702+ context ,
703+ measured . cell ,
704+ sheet . rows [ index ] ?. [ column ] ?? "" ,
705+ numeric ,
706+ ) + measured . cell . padding ,
707+ ) ;
708+ }
709+ return fitted ( widest ) ;
710+ }
711+
712+ function autoFit ( column : number ) {
713+ if ( metrics === null || context === null ) return ;
714+ resizeColumn ( column , columnWidth ( context , metrics , column ) ) ;
715+ }
716+
717+ function startResize (
718+ column : number ,
719+ event : ReactPointerEvent < HTMLDivElement > ,
720+ ) {
721+ const from = widths ?. [ column ] ;
722+ if ( from === undefined ) return ;
723+ event . preventDefault ( ) ;
724+ const handle = event . currentTarget ;
725+ const start = event . clientX ;
726+ handle . setPointerCapture ( event . pointerId ) ;
727+ const move = ( next : PointerEvent ) =>
728+ resizeColumn ( column , from + next . clientX - start ) ;
729+ const end = ( ) => {
730+ handle . removeEventListener ( "pointermove" , move ) ;
731+ handle . removeEventListener ( "pointerup" , end ) ;
732+ handle . removeEventListener ( "pointercancel" , end ) ;
733+ } ;
734+ handle . addEventListener ( "pointermove" , move ) ;
735+ handle . addEventListener ( "pointerup" , end ) ;
736+ handle . addEventListener ( "pointercancel" , end ) ;
737+ }
534738
535739 const selectCell = useCallback (
536740 ( cell : Cell , extend : boolean ) => {
@@ -631,6 +835,20 @@ function CsvGrid({
631835 // Lowercased so Caps Lock or Shift does not turn Ctrl+C into a no-op.
632836 const key = event . key . length === 1 ? event . key . toLowerCase ( ) : event . key ;
633837
838+ if ( event . altKey && at . column >= 0 && widths !== null ) {
839+ const width = widths [ at . column ] ?? COLUMN_MIN_PX ;
840+ const step = RESIZE_STEP_PX * ( event . shiftKey ? 4 : 1 ) ;
841+ if ( key === "ArrowLeft" || key === "ArrowRight" ) {
842+ event . preventDefault ( ) ;
843+ resizeColumn ( at . column , width + ( key === "ArrowLeft" ? - step : step ) ) ;
844+ return ;
845+ }
846+ if ( key === "Enter" ) {
847+ event . preventDefault ( ) ;
848+ autoFit ( at . column ) ;
849+ return ;
850+ }
851+ }
634852 if ( command && key === "a" ) {
635853 event . preventDefault ( ) ;
636854 selectAll ( ) ;
@@ -732,6 +950,7 @@ function CsvGrid({
732950 wording . cellCount ( summary . count ) ,
733951 ...( summary . numbers > 0 ? [ wording . sum ( summary . sum ) ] : [ ] ) ,
734952 ] ;
953+ const frozen = widths !== null && gutterWidth !== null && dataWidth !== null ;
735954
736955 return (
737956 < div className = "flex h-full min-h-0 min-w-0 flex-col gap-2" >
@@ -758,7 +977,11 @@ function CsvGrid({
758977 aria-label = { caption }
759978 aria-multiselectable = "true"
760979 aria-rowcount = { view . length + 2 }
761- className = "min-w-full border-separate border-spacing-0 [&_td]:max-w-80"
980+ className = { cn (
981+ "border-separate border-spacing-0" ,
982+ frozen ? "table-fixed" : "min-w-full [&_td]:max-w-80" ,
983+ ) }
984+ style = { frozen ? { width : dataWidth } : undefined }
762985 onKeyDown = { onKeyDown }
763986 onFocus = { ( ) => {
764987 hadFocus . current = true ;
@@ -769,6 +992,14 @@ function CsvGrid({
769992 }
770993 } }
771994 >
995+ { frozen ? (
996+ < colgroup >
997+ < col style = { { width : gutterWidth } } />
998+ { widths . map ( ( width , index ) => (
999+ < col key = { index } style = { { width } } />
1000+ ) ) }
1001+ </ colgroup >
1002+ ) : null }
7721003 < thead className = "sticky top-0 z-3" >
7731004 < tr aria-rowindex = { 1 } >
7741005 < th
@@ -823,14 +1054,15 @@ function CsvGrid({
8231054 key = { index }
8241055 scope = "col"
8251056 aria-sort = { ariaSort ( direction ) }
826- className = "h-8 border-r border-b border-border bg-card p-0 text-left font-semibold"
1057+ className = "relative h-8 border-r border-b border-border bg-card p-0 text-left font-semibold"
8271058 >
8281059 < SortHeaderButton
8291060 direction = { direction }
8301061 data-cell = { `-1:${ index } ` }
8311062 tabIndex = {
8321063 at . row === - 1 && at . column === index ? 0 : - 1
8331064 }
1065+ aria-keyshortcuts = { RESIZE_KEYS }
8341066 onFocus = { ( ) => setActive ( { row : - 1 , column : index } ) }
8351067 onClick = { ( ) => setSort ( nextSort ( sort , index ) ) }
8361068 className = { cn (
@@ -842,6 +1074,13 @@ function CsvGrid({
8421074 { columnTitle ( sheet , index , wording ) }
8431075 </ span >
8441076 </ SortHeaderButton >
1077+ < div
1078+ aria-hidden
1079+ title = { wording . resizeHint }
1080+ onPointerDown = { ( event ) => startResize ( index , event ) }
1081+ onDoubleClick = { ( ) => autoFit ( index ) }
1082+ className = "absolute inset-y-0 -right-1 z-1 w-2 cursor-col-resize touch-none hover:bg-ring/40"
1083+ />
8451084 </ th >
8461085 ) ;
8471086 } ) }
0 commit comments