Step through pivot depth instead of listing it

One button per level grows with the pivot: the saved layouts here run 3-5
group_by and 2-3 split_by, so "Customer by Program" rendered ten depth
buttons, and with the layout menu, Save and Fit that is thirteen controls
in the view zone. The active level was a blue button among the greys, so
reading the current state meant scanning for it.

A stepper is three controls whatever the depth and names where you are.
The full list is still there behind the middle button, for jumping rather
than stepping.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-21 21:54:02 -04:00
parent 05631817ae
commit 2fdf113636
2 changed files with 85 additions and 24 deletions

View File

@ -238,10 +238,18 @@ Both axes collapse the same way: a **depth in `ViewConfig`**, set through
- **Columns** — `split_by_depth`, driven by the `Columns` buttons via
`applySplitDepth()`
Both sets are drawn by `components/DepthButtons.jsx` from the axis's own field
list (`groupFull` / `splitFull`), so each button is named after the level it
reveals. The row axis used to be a fixed `0 1 2 3`, which named nothing and
offered levels a two-field `group_by` does not have.
Both axes are drawn by `components/DepthButtons.jsx` from the axis's own field
list (`groupFull` / `splitFull`), as a **stepper**: `◂ partgroup ▸`, with the
middle button opening the full level list for a jump rather than a step.
Three controls per axis whatever the depth. It was one button per level, which
grows with the pivot — a 5-by-3 layout rendered ten of them — and the current
level was a blue button among the greys, so reading the state meant scanning.
Before that the row axis was a fixed `0 1 2 3`, which named nothing and offered
levels a two-field `group_by` does not have.
`depth` is null until the first layout has been read back; the stepper shows an
em dash rather than guessing a level.
Both are **1-based**: they count the levels to show, where the imperative
`view.set_depth()` counts the boundary below them. `server.cpp` does

View File

@ -1,26 +1,79 @@
// One axis's collapse control. Rows and columns are the same thing — a depth in
// ViewConfig, counting the levels to show — so they are drawn by one component
// rather than two that happen to look alike.
import { useEffect, useRef, useState } from 'react'
// One axis's collapse control, as a stepper.
//
// The buttons are named after the level they reveal. The row axis used to be a
// fixed 0 1 2 3: a number means nothing without already knowing what group_by
// holds, and a fixed range offers levels that do not exist while hiding ones
// that do.
// 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' }) {
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)))
const arrow = 'border border-gray-200 rounded px-1 py-0.5 text-gray-500 leading-none ' +
'hover:border-gray-400 disabled:opacity-30 disabled:hover:border-gray-200 transition-colors'
return (
<div className="flex items-center gap-1.5">
<span className="pf-section-label">{label}</span>
{Array.from({ length: levels.length + 1 }, (_, n) => (
<button key={n} onClick={() => onPick(n)}
title={n === 0
? `Collapse ${label.toLowerCase()} to a single total`
: `Show ${label.toLowerCase()} down to ${levels.slice(0, n).join(' › ')}`}
className={`border rounded px-1.5 py-0.5 transition-colors max-w-[9rem] truncate
${depth === n ? 'border-blue-300 text-blue-600 bg-blue-50'
: 'border-gray-200 text-gray-500 hover:border-gray-400'}`}>
{n === 0 ? totalLabel : levels[n - 1]}
</button>
))}
<div ref={boxRef} className="relative flex items-center gap-1">
<span className="pf-section-label mr-0.5">{label}</span>
<button className={arrow} disabled={!known || depth <= 0}
title={`Collapse ${label.toLowerCase()} by one level`}
onClick={() => step((depth ?? 0) - 1)}>◂</button>
<button onClick={() => setOpen(o => !o)}
title={`${label}: showing ${current}`}
className={`border rounded px-2 py-0.5 max-w-[10rem] truncate transition-colors
${open ? 'border-blue-300 text-blue-700 bg-blue-50'
: 'border-gray-200 text-gray-600 hover:border-gray-400'}`}>
{current}
</button>
<button className={arrow} disabled={!known || depth >= max}
title={`Expand ${label.toLowerCase()} by one level`}
onClick={() => step((depth ?? 0) + 1)}>▸</button>
{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>
)
}