feat: show secondary detail actions as buttons while the bar has room for them

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-10-13 08:32:30 -07:00
co-authored by Claude Opus 5.5
parent 66195ce9dc
commit 5b94c07849
8 changed files with 62 additions and 19 deletions
@@ -78,6 +78,7 @@ The row's height matches the sidebar's own header row, so the two read as one ba
actions:
actions.length === 0 ? [0] : actionsRigid ? actionWidths : [GAP + FLEXIBLE_ACTIONS_RESERVE],
folds: actions.length > 0 && actionsRigid ? (content?.actionFolds ?? 0) : 0,
earlyFolds: content?.earlyActionFolds,
natural: {
family: trailMeasures.family,
fork: trailMeasures.fork,
@@ -19,6 +19,7 @@ the header, so nesting is decided by the caller rather than guessed from context
actionsFlexible,
actionsFill,
actionFolds,
earlyActionFolds,
belowBar,
belowBarFold,
separator,
@@ -37,6 +38,7 @@ the header, so nesting is decided by the caller rather than guessed from context
actionsFlexible,
actionsFill,
actionFolds,
earlyActionFolds,
belowBar,
belowBarFold,
separator,
@@ -20,10 +20,13 @@
/** Shown under the label once the button is a row of a menu. */
description?: string
buttonProps: ButtonProps
/** A secondary action, kept out of the bar at every width: in the ellipsis menu, or in the
* dropdown of the enabled bar button labelled `dropdownOf` (the menu when there is none).
* Unset makes it a bar button, which folds into the menu only when the bar is short. */
/** A secondary action: a bar button while the bar has room for every one, then, from the
* first fold, in the ellipsis menu or in the dropdown of the enabled bar button labelled
* `dropdownOf` (the menu when there is none). Unset makes it a main button, which goes to
* the menu only on the folds after that. */
placement?: 'menu' | { dropdownOf: string }
/** In its `placement` at every width, never a bar button of its own. */
alwaysPlaced?: boolean
}
type ButtonProps = any
@@ -101,18 +104,27 @@
// no error handler and says so itself.
const renameKind = $derived(itemKind ?? errorHandlerKind)
// The band folds the bar buttons once its breadcrumb has given all it can (`actionFolds` below):
// first every one but the last, which is what the page is for (Edit), then that one too. The
// host knows when its own box is narrow while the window is not — a session's preview panel —
// and says so through `wide`, which only sends the error handler's toggle to the menu.
// The band folds the buttons once its breadcrumb has given all it can (`actionFolds` below):
// first the secondary ones go to their `placement`, then every bar button but the last, which
// is what the page is for (Edit), then that one too. The host knows when its own box is narrow
// while the window is not — a session's preview panel — and says so through `wide`, which only
// sends the error handler's toggle to the menu.
const fold = $derived(ownsPageHeader ? pageHeader.actionFold : 0)
const wideRow = $derived(wide && fold === 0)
const wideRow = $derived(wide && fold <= 1)
const rowButtons = $derived(mainButtons.filter((b) => !b.placement))
const barButtons = $derived(fold >= 2 ? [] : fold === 1 ? rowButtons.slice(-1) : rowButtons)
const barButtons = $derived(
fold === 0
? mainButtons.filter((b) => !b.alwaysPlaced)
: fold === 1
? rowButtons
: fold === 2
? rowButtons.slice(-1)
: []
)
function dropdownHost(btn: MainButton): MainButton | undefined {
if (typeof btn.placement !== 'object') return undefined
if (typeof btn.placement !== 'object' || barButtons.includes(btn)) return undefined
const label = btn.placement.dropdownOf
return barButtons.find((b) => b.label === label && !b.buttonProps.disabled)
}
@@ -121,7 +133,9 @@
* dropdown would have held, then the secondary actions with no bar button to hang from. */
const menuButtons = $derived.by(() => {
const folded = rowButtons.filter((b) => !barButtons.includes(b))
const homeless = mainButtons.filter((b) => b.placement && !dropdownHost(b))
const homeless = mainButtons.filter(
(b) => b.placement && !barButtons.includes(b) && !dropdownHost(b)
)
const childOf = (host: MainButton) =>
homeless.filter(
(b) => typeof b.placement === 'object' && b.placement.dropdownOf === host.label
@@ -295,7 +309,8 @@
pathTrigger: onSaved ? pathTrigger : undefined
}}
{actions}
actionFolds={2}
actionFolds={3}
earlyActionFolds={1}
contexts={headerContexts}
separator="always"
/>
@@ -436,6 +436,7 @@
buttons.push({
label: 'Build app',
placement: 'menu',
alwaysPlaced: true,
buttonProps: {
onClick: async () => {
const app = createRawAppFromFlow(flow.path, flow.summary, flow.schema)
@@ -603,6 +603,7 @@
buttons.push({
label: 'Build app',
placement: 'menu',
alwaysPlaced: true,
buttonProps: {
onClick: async () => {
const app = createRawAppFromScript(script.path, script.summary, script.schema)
@@ -57,6 +57,9 @@ export type PageHeaderContent = {
* all it can, and the page reads it back as `pageHeader.actionFold`. Without it the actions
* never fold and the breadcrumb gives up width around them. */
actionFolds?: number
/** How many of the `actionFolds` levels come before the breadcrumb drops anything: buttons
* that are there only while the bar has room for everything. */
earlyActionFolds?: number
/** A second row of the band, under the breadcrumb and the actions: where a control too wide
* for one row goes at a fold that has no room for it beside them, rather than being squeezed
* until it is useless. The band is taller while it is there. */
@@ -167,6 +170,7 @@ export const pageHeader = {
if (c.actionsFlexible !== undefined) merged.actionsFlexible = c.actionsFlexible
if (c.actionsFill !== undefined) merged.actionsFill = c.actionsFill
if (c.actionFolds !== undefined) merged.actionFolds = c.actionFolds
if (c.earlyActionFolds !== undefined) merged.earlyActionFolds = c.earlyActionFolds
if (c.belowBar !== undefined) merged.belowBar = c.belowBar
if (c.belowBarFold !== undefined) merged.belowBarFold = c.belowBarFold
if (c.separator !== undefined) merged.separator = c.separator
@@ -26,6 +26,14 @@ describe('fitBar', () => {
expect(fitBar({ ...base, barWidth: 400 })).toMatchObject({ stage: 5, fold: 2 })
})
it('spends early folds before the breadcrumb drops anything', () => {
// 600 + 200 fits an 800px bar at the first fold; without early folds stage 2 would have
// dropped parts beside the full row instead.
expect(fitBar({ ...base, barWidth: 800, earlyFolds: 1 })).toMatchObject({ stage: 0, fold: 1 })
// Past that, the breadcrumb gives way at the early fold before the later folds start.
expect(fitBar({ ...base, barWidth: 700, earlyFolds: 1 })).toMatchObject({ stage: 2, fold: 1 })
})
it('tries a level it has not seen at the width of the next one folded further', () => {
// Only the second fold has been measured: the levels before it are assumed no wider, so the
// bar tries the full row first and goes on to the first fold once it has seen it overflow.
@@ -122,6 +122,9 @@ export type BarMeasure = {
actions: (number | undefined)[]
/** How many levels past the full row the page's actions can fold through. */
folds: number
/** How many of those levels fold before the breadcrumb drops anything: buttons that are there
* only while there is room for everything. */
earlyFolds?: number
natural: Record<Part, number>
}
@@ -151,19 +154,27 @@ export function fitBar(m: BarMeasure): BarFit {
const floorWidth = (s: number, f: number) =>
m.chrome + (m.trail[s] ?? 0) + (m.summary[s] ?? 0) + actionsAt(f)
const early = Math.min(m.earlyFolds ?? 0, m.folds)
let stage = last
let fold = m.folds
choose: {
for (let s = 0; s <= last; s++) {
if (floorWidth(s, 0) <= m.barWidth) {
stage = s
fold = 0
for (let f = 0; f < early; f++) {
if (floorWidth(0, f) <= m.barWidth) {
stage = 0
fold = f
break choose
}
}
// The actions fold only once the breadcrumb has given all it can, so a narrowing bar only
// ever loses things: the parts it dropped do not come back when the buttons fold.
for (let f = 1; f <= m.folds; f++) {
for (let s = 0; s <= last; s++) {
if (floorWidth(s, early) <= m.barWidth) {
stage = s
fold = early
break choose
}
}
// The other levels fold only once the breadcrumb has given all it can, so a narrowing bar
// only ever loses things: the parts it dropped do not come back when the buttons fold.
for (let f = early + 1; f <= m.folds; f++) {
if (floorWidth(last, f) <= m.barWidth) {
fold = f
break choose