1- import { Children , type ReactNode } from 'react' ;
1+ import { Children , useLayoutEffect , useRef , type ReactNode , type RefObject } from 'react' ;
22import { LoadingSpinner } from '@/components/ui/loading-spinner' ;
33import { useTranslation } from 'react-i18next' ;
44import { FileText , FileDown , MessageSquare } from 'lucide-react' ;
@@ -17,15 +17,88 @@ interface ExportToolbarProps {
1717 extraExports ?: ReactNode ;
1818 /** Buttons that appear on the top row (Save, Discuss, Flowcharts, Undo, etc.). */
1919 secondaryActions ?: ReactNode ;
20- /** On a phone, both rows stretch their buttons into even pairs and the EXPORT tag takes its own line. */
20+ /**
21+ * Rows that wrap fill evenly. On a phone both rows stretch into pairs and the EXPORT tag takes its
22+ * own line; wider, a row that needs two lines splits its buttons evenly (3+2, 3+3) and stretches them.
23+ */
2124 evenRows ?: boolean ;
2225}
2326
2427// On a phone each button grows from half the row, so two share a line and a
2528// label too long for half takes the whole line; nothing is left ragged. Buttons
2629// set their own touch min-width, which would let a long label shrink into half
2730// a row, so the width of the label wins.
28- const EVEN_ROW = 'max-sm:[&>*]:grow max-sm:[&>*]:basis-[calc(50%-0.25rem)] max-sm:[&>*]:whitespace-nowrap max-sm:[&>*]:min-w-fit!' ;
31+ const EVEN_ROW = 'max-sm:[&>*]:grow max-sm:[&>*]:basis-[calc(50%-0.25rem)] [&>*]:whitespace-nowrap [&>*]:min-w-fit!' ;
32+
33+ const WIDE = '(min-width: 40rem)' ;
34+
35+ type Split = { lines : number [ ] [ ] ; spread : number ; widest : number } ;
36+
37+ // Every way to cut the items into consecutive lines that each fit. The best split uses the fewest
38+ // lines, then keeps their counts closest (3+2 over 4+1), then the narrowest widest line.
39+ function balancedSplit ( widths : number [ ] , room : number , gap : number ) : number [ ] [ ] {
40+ let best : Split | null = null ;
41+ const cuts = 1 << ( widths . length - 1 ) ;
42+ for ( let mask = 0 ; mask < cuts ; mask ++ ) {
43+ const lines : number [ ] [ ] = [ [ 0 ] ] ;
44+ for ( let i = 1 ; i < widths . length ; i ++ ) {
45+ if ( mask & ( 1 << ( i - 1 ) ) ) lines . push ( [ i ] ) ;
46+ else lines [ lines . length - 1 ] . push ( i ) ;
47+ }
48+ const lengths = lines . map ( ( line ) => line . reduce ( ( sum , i ) => sum + widths [ i ] , 0 ) + gap * ( line . length - 1 ) ) ;
49+ if ( lengths . some ( ( length ) => length > room ) ) continue ;
50+ const counts = lines . map ( ( line ) => line . length ) ;
51+ const split = { lines, spread : Math . max ( ...counts ) - Math . min ( ...counts ) , widest : Math . max ( ...lengths ) } ;
52+ if ( ! best || split . lines . length < best . lines . length
53+ || ( split . lines . length === best . lines . length && ( split . spread < best . spread
54+ || ( split . spread === best . spread && split . widest < best . widest ) ) ) ) best = split ;
55+ }
56+ return best ?. lines ?? widths . map ( ( _ , i ) => [ i ] ) ;
57+ }
58+
59+ // From 640px up, a row that fits stays one natural line. One that wraps is cut into balanced lines,
60+ // and each line's items grow in proportion to their labels until the line is full. An item marked
61+ // data-row-fixed (the EXPORT tag) keeps its own width. Phones keep the CSS pairs above.
62+ function useBalancedRow ( ref : RefObject < HTMLDivElement > , enabled : boolean ) {
63+ useLayoutEffect ( ( ) => {
64+ const row = ref . current ;
65+ if ( ! enabled || ! row || typeof ResizeObserver === 'undefined' ) return ;
66+ const items = ( ) => Array . from ( row . children ) as HTMLElement [ ] ;
67+ const reset = ( ) => items ( ) . forEach ( ( item ) => { item . style . flexBasis = '' ; item . style . flexGrow = '' ; } ) ;
68+ const wide = window . matchMedia ( WIDE ) ;
69+ let live = true ;
70+ const layout = ( ) => {
71+ if ( ! live ) return ;
72+ reset ( ) ;
73+ const room = row . clientWidth ;
74+ const all = items ( ) ;
75+ if ( ! wide . matches || room === 0 || all . length < 2 ) return ;
76+ const gap = parseFloat ( getComputedStyle ( row ) . columnGap ) || 0 ;
77+ const widths = all . map ( ( item ) => item . getBoundingClientRect ( ) . width ) ;
78+ const lines = balancedSplit ( widths , room , gap ) ;
79+ if ( lines . length < 2 ) return ;
80+ for ( const line of lines ) {
81+ const fixed = line . filter ( ( i ) => all [ i ] . dataset . rowFixed !== undefined ) ;
82+ const fixedWidth = fixed . reduce ( ( sum , i ) => sum + widths [ i ] , 0 ) ;
83+ const growing = line . filter ( ( i ) => ! fixed . includes ( i ) ) ;
84+ const labels = growing . reduce ( ( sum , i ) => sum + widths [ i ] , 0 ) ;
85+ const free = room - gap * ( line . length - 1 ) - fixedWidth ;
86+ for ( const i of line ) {
87+ // Half a pixel short of full, so rounding never pushes the next line's first item up.
88+ const basis = fixed . includes ( i ) ? widths [ i ] : ( free * widths [ i ] ) / labels - 0.5 ;
89+ all [ i ] . style . flexBasis = `${ basis } px` ;
90+ all [ i ] . style . flexGrow = fixed . includes ( i ) ? '0' : '1' ;
91+ }
92+ }
93+ } ;
94+ layout ( ) ;
95+ const observer = new ResizeObserver ( layout ) ;
96+ observer . observe ( row ) ;
97+ wide . addEventListener ( 'change' , layout ) ;
98+ void document . fonts ?. ready . then ( layout ) ;
99+ return ( ) => { live = false ; observer . disconnect ( ) ; wide . removeEventListener ( 'change' , layout ) ; reset ( ) ; } ;
100+ } ) ;
101+ }
29102
30103// Unified export bar used by every feature's results view.
31104// Two explicit rows, both left-aligned:
@@ -44,6 +117,10 @@ export function ExportToolbar({
44117 evenRows = false ,
45118} : ExportToolbarProps ) {
46119 const { t } = useTranslation ( ) ;
120+ const topRow = useRef < HTMLDivElement > ( null ) ;
121+ const exportRow = useRef < HTMLDivElement > ( null ) ;
122+ useBalancedRow ( topRow , evenRows ) ;
123+ useBalancedRow ( exportRow , evenRows ) ;
47124
48125 const hasSecondaryActions = Children . toArray ( secondaryActions ) . filter ( Boolean ) . length > 0 ;
49126 const hasTopRow = hasSecondaryActions || ! ! onDiscuss ;
@@ -56,7 +133,7 @@ export function ExportToolbar({
56133 return (
57134 < div className = "flex w-full flex-col items-start gap-2" >
58135 { hasTopRow && (
59- < div className = { cn ( 'flex w-full flex-wrap items-center justify-start gap-2' , evenRows && EVEN_ROW ) } >
136+ < div ref = { topRow } className = { cn ( 'flex w-full flex-wrap items-center justify-start gap-2' , evenRows && EVEN_ROW ) } >
60137 { secondaryActions }
61138 { onDiscuss && (
62139 < Button variant = "outline" size = "sm" className = "whitespace-nowrap" onClick = { onDiscuss } >
@@ -66,8 +143,8 @@ export function ExportToolbar({
66143 ) }
67144 </ div >
68145 ) }
69- < div className = { cn ( 'flex w-full flex-wrap items-center justify-start gap-2' , evenRows && EVEN_ROW ) } >
70- { evenRows ? < div className = "flex max-sm:basis-full!" > { exportTag } </ div > : exportTag }
146+ < div ref = { exportRow } className = { cn ( 'flex w-full flex-wrap items-center justify-start gap-2' , evenRows && EVEN_ROW ) } >
147+ { evenRows ? < div data-row-fixed className = "flex max-sm:basis-full!" > { exportTag } </ div > : exportTag }
71148 { extraExports }
72149 < Button variant = "outline" size = "sm" className = "whitespace-nowrap" onClick = { onPdf } >
73150 < FileText className = "mr-2 h-4 w-4" />
0 commit comments