Hold the toolbar's widths still while stepping and editing

The stepper's middle is sized to the axis's longest level rather than the
current one, and Save and the dirty dot keep their slots while hidden, so
nothing shifts under the cursor.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-25 13:06:22 -04:00
parent 45e45bfa60
commit b8b5220583
2 changed files with 20 additions and 6 deletions

View File

@ -35,6 +35,9 @@ export default function DepthButtons({ label, levels, depth, onPick, totalLabel
const known = depth != null
const current = !known ? '—' : (depth === 0 ? totalLabel : levels[depth - 1])
const step = (d) => onPick(Math.min(max, Math.max(0, d)))
// Capped so one long field name cannot push the rest of the toolbar off;
// past the cap the label truncates, with the full name in the title.
const widest = Math.min(18, Math.max(...[totalLabel, ...levels].map(l => String(l).length)))
// One joined control, in the style of the operation panel's
// Scale | Recode | Clone. The arrows used to be separate outlined boxes a
@ -53,9 +56,13 @@ export default function DepthButtons({ label, levels, depth, onPick, totalLabel
title={`Collapse ${label.toLowerCase()} by one level`}
onClick={() => step((depth ?? 0) - 1)}>◂</button>
{/* Sized to the axis's longest level, not the current one. Sized to
its content it resized on every step, so the ▸ moved out from
under the cursor between clicks. */}
<button onClick={() => setOpen(o => !o)}
title={`${label}: showing ${current}`}
className={`px-2 py-0.5 max-w-[10rem] truncate transition-colors
style={{ width: `calc(${widest}ch + 1rem)` }}
className={`px-2 py-0.5 truncate text-left transition-colors
${open ? 'bg-blue-50 text-blue-700' : 'bg-white text-gray-700 hover:bg-gray-100'}`}>
{current}
</button>

View File

@ -123,15 +123,22 @@ export default function LayoutMenu({
${open ? 'border-blue-300 text-blue-700 bg-blue-50'
: 'border-gray-200 text-gray-600 hover:border-gray-400'}`}>
<span className="truncate">{active ? active.name : 'Unsaved'}</span>
{dirty && <span title="Changed since it was saved" className="text-amber-500 leading-none">•</span>}
{/* Always rendered, hidden when clean, so the button does not widen
by a dot the moment the pivot changes. */}
<span title={dirty ? 'Changed since it was saved' : undefined}
className={`text-amber-500 leading-none ${dirty ? '' : 'invisible'}`}>•</span>
<span className="text-gray-400 leading-none">▾</span>
</button>
{/* Save is the common action and stays outside the menu, but only when
there is something to save it to and it is yours to overwrite. */}
{active?.can_edit && dirty && (
<button onClick={() => onSave(active)}
className="border border-blue-200 text-blue-600 hover:text-blue-800 rounded px-2 py-0.5">
there is something to save it to and it is yours to overwrite. Its
slot is held while the layout is clean: appearing and vanishing, it
shoved the Rows and Columns controls sideways under the cursor. */}
{active?.can_edit && (
<button onClick={() => onSave(active)} disabled={!dirty}
aria-hidden={!dirty} tabIndex={dirty ? 0 : -1}
className={`border border-blue-200 text-blue-600 hover:text-blue-800 rounded px-2 py-0.5
${dirty ? '' : 'invisible'}`}>
Save
</button>
)}