pf_app/ui/src/components/DepthButtons.jsx
Paul Trowbridge b8b5220583 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>
2026-09-25 13:06:22 -04:00

109 lines
5.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useRef, useState } from 'react'
// One axis's collapse control, as a stepper.
//
// Rows and columns are the same thing — a depth in ViewConfig, counting the
// levels to show — so one component draws both.
//
// It used to be one button per level, which is N+1 controls that grow with the
// pivot: a 5-by-3 layout rendered ten of them, and the active level was a blue
// button somewhere among the greys, so reading the current state meant scanning.
// A stepper is three controls whatever the depth, and says where you are rather
// than making you find it. The full list is still one click away, for jumping
// rather than stepping.
export default function DepthButtons({ label, levels, depth, onPick, totalLabel = 'Total', children }) {
const [open, setOpen] = useState(false)
const boxRef = useRef(null)
useEffect(() => {
if (!open) return
const onDown = (e) => { if (!boxRef.current?.contains(e.target)) setOpen(false) }
const onKey = (e) => { if (e.key === 'Escape') setOpen(false) }
// Capture phase: the pivot lives in a shadow root and retargets its events,
// so a bubbled listener sees the host rather than the real target.
document.addEventListener('mousedown', onDown, true)
window.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onDown, true)
window.removeEventListener('keydown', onKey)
}
}, [open])
const max = levels.length
// depth is null until the first layout has been read back. Showing the last
// level would be a guess; an em dash is honest and the arrows still work.
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
// few pixels wide, which in dark mode were barely visible and looked the
// same enabled or disabled; joined, the axis reads as one object and the
// arrows are halves of it rather than specks beside it.
const seg = 'px-1.5 py-0.5 leading-none bg-white transition-colors ' +
'text-gray-600 hover:bg-gray-100 disabled:text-gray-300 disabled:hover:bg-white disabled:cursor-default'
return (
<div ref={boxRef} className="relative flex items-center gap-1.5">
<span className="pf-section-label">{label}</span>
<div className="inline-flex rounded border border-gray-300 divide-x divide-gray-300 overflow-hidden">
<button className={seg} disabled={!known || depth <= 0}
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}`}
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>
<button className={seg} disabled={!known || depth >= max}
title={`Expand ${label.toLowerCase()} by one level`}
onClick={() => step((depth ?? 0) + 1)}>▸</button>
</div>
{children}
{open && (
<div className="absolute top-full left-0 mt-1 z-50 min-w-[10rem] bg-white border
border-gray-200 rounded shadow-lg py-1 text-xs">
{Array.from({ length: max + 1 }, (_, n) => (
<button key={n} onClick={() => { onPick(n); setOpen(false) }}
title={n === 0
? `Collapse ${label.toLowerCase()} to a single total`
: `Show ${label.toLowerCase()} down to ${levels.slice(0, n).join(' › ')}`}
className={`block w-full text-left px-3 py-1 truncate
${depth === n ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-50'}`}>
{n === 0 ? totalLabel : levels[n - 1]}
</button>
))}
</div>
)}
</div>
)
}
// An on/off control that looks pressed when on — the same state the toolbar's
// Operations button uses for "open". Replaces the native checkboxes, which
// drew in the light style on the dark theme and were the brightest thing on
// the toolbar.
export function ToggleChip({ on, onChange, title, children }) {
return (
<button type="button" aria-pressed={on} title={title} onClick={() => onChange(!on)}
className={`border rounded px-2 py-0.5 transition-colors ${
on ? 'pf-pressed' : 'border-gray-300 bg-white text-gray-500 hover:bg-gray-100'}`}>
{children}
</button>
)
}