Keep the price/volume choice on screen when it isn't the one choosing

"Absorbed by" was rendered only while the edit was dollars-only, which made it
absent in two quite different states: before anything had been typed, and after
a measure had been pinned. Pinning includes a *click* -- clicking a cell pins
whatever it reads -- so an accidental click in the qty column had no visible
effect except that the control disappeared, and the way back was a small x in a
table header.

It now stays put and greys out, still showing which measure absorbs the change,
because the pinned inputs answer the same question the toggle does:

    qty pinned   -> price is what moves
    price pinned -> qty is what moves

so the inert control reads as the truth rather than a stale choice. The hint
says which case it is in, and a pinned measure gets an "unpin" next to the
toggle rather than only the x in the header.

Nothing about what gets written changes: `plug` is still sent only when the edit
is dollars-only, which is still the only case where price and volume are both
unknown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-22 13:36:44 -04:00
parent 7006014cc3
commit 9e880a65be

View File

@ -61,11 +61,13 @@ function Block({ title, hint, horizontal, grow, children }) {
)
}
function Button({ onClick, active, children, title }) {
function Button({ onClick, active, children, title, disabled }) {
return (
<button onClick={onClick} title={title}
<button onClick={onClick} title={title} disabled={disabled}
className={`px-3 py-1 rounded text-xs whitespace-nowrap transition-colors ${
active ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50'}`}>
disabled
? (active ? 'bg-gray-300 text-white cursor-default' : 'bg-white text-gray-400 cursor-default')
: (active ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50')}`}>
{children}
</button>
)
@ -99,11 +101,16 @@ function SectionLabel({ children }) {
)
}
function Segmented({ options, value, onChange }) {
// `disabled` keeps the control in place and shows which way it is set rather
// than removing it. A control that vanishes reads as broken; one that greys out
// says the choice has been made elsewhere.
function Segmented({ options, value, onChange, disabled }) {
return (
<div className="inline-flex rounded border border-gray-200 overflow-hidden w-auto self-start">
<div className={`inline-flex rounded border overflow-hidden w-auto self-start ${
disabled ? 'border-gray-200 bg-gray-50' : 'border-gray-200'}`}>
{options.map(([val, label, title]) => (
<Button key={val} onClick={() => onChange(val)} active={value === val} title={title}>{label}</Button>
<Button key={val} onClick={() => onChange(val)} active={value === val}
title={title} disabled={disabled}>{label}</Button>
))}
</div>
)
@ -731,6 +738,34 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
? (scalePlug === 'price' ? 'units' : 'price')
: null
// Which measure is absorbing the change, whether or not the toggle chose it.
//
// The toggle used to be rendered only while the edit was dollars-only, so it
// was absent before anything was typed and disappeared the moment a measure
// was pinned -- including by a click, since clicking a cell pins whatever it
// reads. Two different states both looked like the control was missing, and
// an accidental click into the qty column had no visible effect except that
// the control went away. It now stays put in every state and greys out when
// the inputs have already answered it, which they genuinely have:
// qty pinned -> price is what moves
// price pinned -> qty is what moves
// so the inert control still shows the truth rather than a stale choice.
const plugState = (() => {
if (!(valueCol && unitsCol)) return null
const u = filled('units'), p = filled('price'), v = filled('value')
if (u && p) return { value: null, live: false, why: 'Both qty and price are named, so the dollars follow from them.' }
if (u) return { value: 'price', live: false, why: `${unitsCol} is pinned, so price absorbs the change.`, unpin: 'units' }
if (p) return { value: 'volume', live: false, why: `Price is pinned, so ${unitsCol} absorbs the change.`, unpin: 'price' }
if (!v) return { value: null, live: false, why: 'Applies to a dollar figure entered on its own — nothing else names price or qty.' }
return {
value: scalePlug,
live: true,
why: scalePlug === 'price'
? `${unitsCol} holds; price moves to reach the number.`
: `Price holds; ${unitsCol} scales with the dollars.`,
}
})()
const numCell = 'text-right font-mono tabular-nums whitespace-nowrap px-2'
const rule = <td className="p-0"><div className="border-t border-gray-300 my-1" /></td>
@ -909,27 +944,37 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
</tbody>
</table>
{/* A dollar figure on its own does not say whether price or volume moved.
Only ask once the edit is actually dollars-only -- naming units or price
has already answered it, and the control would just be noise. */}
{/* A dollar figure on its own does not say whether price or volume moved,
so the toggle answers for it. Kept visible even when it is not the one
answering -- see plugState -- because a control that comes and goes
reads as a fault, and its absence was the only sign that a stray click
had pinned a measure. */}
{/* Both of these are settings on the edit, so they use Field like every
other one -- an aligned label column and a hint below the control.
They used to hand-roll their own label row and hint, with two
different type sizes and no shared label width, so the two stacked
into a paragraph of loose words rather than reading as two settings. */}
{(valueCol && unitsCol && dollarsOnly || hasExcl || perSlice) && (
{(plugState || hasExcl || perSlice) && (
<div className="flex flex-col gap-2 mt-3 pt-3 border-t border-gray-100">
{valueCol && unitsCol && dollarsOnly && (
<Field label="Absorbed by">
{plugState && (
<Field label="Absorbed by" hint={plugState.why}>
<Segmented
value={scalePlug}
value={plugState.value}
onChange={setScalePlug}
disabled={!plugState.live}
options={[
['price', 'Price', `${unitsCol} holds; price moves to reach the number`],
['volume', 'Volume', `Price holds; ${unitsCol} scales with the dollars`],
]}
/>
{plugState.unpin && (
<button onClick={() => clearEdit(plugState.unpin)}
title={`Stop pinning ${plugState.unpin === 'units' ? unitsCol : 'price'} and choose here instead`}
className="text-blue-600 hover:underline">
unpin {plugState.unpin === 'units' ? unitsCol : 'price'}
</button>
)}
</Field>
)}