Skip to content

Commit 41f5cb3

Browse files
Programcodeclaude
andcommitted
Generate toolbar rows split evenly on tablets and desktop
From 640px up, a Generate toolbar row that fits on one line stays one natural, left-aligned line. A row that has to wrap is cut into lines whose button counts differ by at most one (3+2, not 4+1), and each line's buttons grow in proportion to their labels until the line is full. The Export tag keeps its own width at the start of its line. Phones keep the even pairs from #229. The split is measured, because where a row breaks depends on the language and on whether Save or "Saved to History" is showing. It stays behind the opt-in evenRows prop, so Attack, Brainstorm and Factoids are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent aa92de4 commit 41f5cb3

1 file changed

Lines changed: 83 additions & 6 deletions

File tree

‎src/components/features/ExportToolbar.tsx‎

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Children, type ReactNode } from 'react';
1+
import { Children, useLayoutEffect, useRef, type ReactNode, type RefObject } from 'react';
22
import { LoadingSpinner } from '@/components/ui/loading-spinner';
33
import { useTranslation } from 'react-i18next';
44
import { 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

Comments
 (0)