mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-14 08:02:58 +00:00
feat: shrink the page header in stages and let the bar fold page actions
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a80c6ffacb
commit
23f6e69bf2
@@ -40,7 +40,7 @@
|
||||
import { getContext, onDestroy, setContext, untrack, type Snippet } from 'svelte'
|
||||
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
|
||||
import PathEditPopover from '$lib/components/PathEditPopover.svelte'
|
||||
import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte'
|
||||
import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte'
|
||||
import { writable } from 'svelte/store'
|
||||
import CenteredPage from './CenteredPage.svelte'
|
||||
import { Button } from './common'
|
||||
@@ -230,19 +230,14 @@
|
||||
let pathSnapshot = $state<string | undefined>(undefined)
|
||||
|
||||
let topbarWidth = $state(0)
|
||||
// In the page header the buttons share the row with the breadcrumb, so they collapse sooner:
|
||||
// the trail and the summary take ~560px before either truncates, and the full group — the
|
||||
// dropdown, Diff, the preview trio and Deploy — is ~610px.
|
||||
const compactBelow = $derived(ownsPageHeader ? 1200 : 720)
|
||||
const compactTopbar = $derived.by(() => {
|
||||
const w = ownsPageHeader ? pageHeader.barWidth : topbarWidth
|
||||
return w > 0 && w < compactBelow
|
||||
})
|
||||
// A phone's bar holds the trail and Deploy: the diff joins the menu and the preview buttons
|
||||
// stand down, since there is no room to run a flow beside its own name.
|
||||
const phoneTopbar = $derived(
|
||||
ownsPageHeader && pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR
|
||||
// In the page header the bar folds the buttons (`actionFolds` below), once its breadcrumb has
|
||||
// given all it can; anywhere else this editor's own top bar is theirs alone.
|
||||
const compactTopbar = $derived(
|
||||
ownsPageHeader ? pageHeader.actionFold >= 1 : topbarWidth > 0 && topbarWidth < 720
|
||||
)
|
||||
// The second fold holds the trail and Deploy: the diff joins the menu and the preview buttons
|
||||
// stand down, since there is no room to run a flow beside its own name.
|
||||
const phoneTopbar = $derived(ownsPageHeader && pageHeader.actionFold >= 2)
|
||||
|
||||
const diffEnabled = $derived(customUi?.topBar?.diff != false)
|
||||
// Nothing to compare against until a deployed version exists.
|
||||
@@ -1597,6 +1592,7 @@
|
||||
: undefined
|
||||
}}
|
||||
actions={flowHeaderActions}
|
||||
actionFolds={2}
|
||||
contexts={headerContexts}
|
||||
separator="none"
|
||||
/>
|
||||
|
||||
@@ -16,7 +16,6 @@ not this component) — so the lead can run the whole line.
|
||||
import WorkspaceItemKindIcon from './WorkspaceItemKindIcon.svelte'
|
||||
import { Menu, Menubar } from '$lib/components/meltComponents'
|
||||
import MeltButton from '$lib/components/meltComponents/MeltButton.svelte'
|
||||
import BreadcrumbItemContent from './BreadcrumbItemContent.svelte'
|
||||
import WorkspacePickerBody from '$lib/components/sidebar/WorkspacePickerBody.svelte'
|
||||
import BreadcrumbSegment from '$lib/components/BreadcrumbSegment.svelte'
|
||||
import { userWorkspaces, workspaceStore, workspaceColor, superadmin } from '$lib/stores'
|
||||
@@ -33,14 +32,25 @@ not this component) — so the lead can run the whole line.
|
||||
import { KIND_LABEL_LOWER, kindKey } from '$lib/components/workspacePicker'
|
||||
import type { PageHeaderItem, PageHeaderSection } from './pageHeaderRegistry.svelte'
|
||||
import type { Snippet } from 'svelte'
|
||||
import {
|
||||
BAR_STAGES,
|
||||
FLOOR,
|
||||
shownPathLevels,
|
||||
type Part,
|
||||
type TrailMeasures,
|
||||
type TrailStage
|
||||
} from './pageHeaderStages'
|
||||
|
||||
let {
|
||||
item,
|
||||
section,
|
||||
afterName,
|
||||
actingWorkspaceId,
|
||||
narrow = false,
|
||||
nameOnly = false
|
||||
nameOnly = false,
|
||||
stage = BAR_STAGES[0],
|
||||
caps = {},
|
||||
scopesGive = false,
|
||||
measures = $bindable()
|
||||
}: {
|
||||
item?: PageHeaderItem
|
||||
section?: PageHeaderSection
|
||||
@@ -49,9 +59,14 @@ not this component) — so the lead can run the whole line.
|
||||
afterName?: Snippet
|
||||
/** The workspace the page acts on, when it differs from the one the app is pointed at. */
|
||||
actingWorkspaceId?: string
|
||||
/** The bar is short of room: the path holds a tighter cap, so it cannot claim a third of the
|
||||
* line before anything else has given way. The names truncate at every width either way. */
|
||||
narrow?: boolean
|
||||
/** Which parts the bar has dropped for room (see pageHeaderStages). */
|
||||
stage?: TrailStage
|
||||
/** Max widths the bar holds the names to, once dropping whole parts is not enough. */
|
||||
caps?: Partial<Record<Part, number>>
|
||||
/** The folders above the name truncate too: even the name at its last floor does not fit. */
|
||||
scopesGive?: boolean
|
||||
/** Filled in for the bar to choose a stage with; left undefined, nothing is measured. */
|
||||
measures?: TrailMeasures
|
||||
/** Only the page's own name, with no workspace part and no picker — the band inside a
|
||||
* session's preview frame, where the workspace is the host's and leading the reader out of
|
||||
* it is exactly what that band must not offer. The page still has to say what it is. */
|
||||
@@ -156,15 +171,8 @@ not this component) — so the lead can run the whole line.
|
||||
twMerge(SEGMENT, 'hover:bg-transparent', workspaceIsLast && !showFork ? LAST : LEAD)
|
||||
)
|
||||
|
||||
// Capped: a draft's path is a 40-character uuid slug, and the trail yields so grudgingly that
|
||||
// one would push a page's buttons off the end of the bar.
|
||||
const pathSegment = $derived(
|
||||
twMerge(
|
||||
SEGMENT,
|
||||
'gap-1.5 overflow-hidden',
|
||||
itemIsLast ? LAST : LEAD,
|
||||
narrow ? 'max-w-[11rem]' : 'max-w-[16rem]'
|
||||
)
|
||||
twMerge(SEGMENT, 'gap-1.5 overflow-hidden', itemIsLast ? LAST : LEAD)
|
||||
)
|
||||
// Copying is the one thing a segment does that leaves no mark on the page, so it is the one
|
||||
// without a hover fill: the pointer and the confirmation under it are the affordance. A segment
|
||||
@@ -191,6 +199,18 @@ not this component) — so the lead can run the whole line.
|
||||
const scopeKind = $derived(item?.path?.split('/')[0])
|
||||
/** The path's levels, the `f`/`u` prefix dropped — that is what the icon says. */
|
||||
const pathLevels = $derived((item?.path ?? '').split('/').slice(1).filter(Boolean))
|
||||
|
||||
// The off-screen copies stand in a fixed-width box for what a page renders into the trail: a
|
||||
// second render of a page's snippet would be a second live copy of its controls.
|
||||
let afterNameWidth = $state(0)
|
||||
let sectionContentWidth = $state(0)
|
||||
|
||||
/** A measured copy holds each name at its floor; the trail on screen at the bar's cap. */
|
||||
function capStyle(part: Part, measure: boolean): string | undefined {
|
||||
if (measure) return `max-width: ${FLOOR[part]}px`
|
||||
const cap = caps[part]
|
||||
return cap === undefined ? undefined : `max-width: ${cap}px`
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet workspaceDisc()}
|
||||
@@ -241,80 +261,114 @@ not this component) — so the lead can run the whole line.
|
||||
<span class={twMerge('shrink-0 text-hint/40 text-xs', extra)} aria-hidden="true">/</span>
|
||||
{/snippet}
|
||||
|
||||
<nav
|
||||
aria-label="Breadcrumb"
|
||||
class="flex items-center gap-1.5 min-w-0 text-xs font-normal text-primary"
|
||||
>
|
||||
{#snippet workspaceMenu_(measure: boolean)}
|
||||
{#if measure}
|
||||
<span class="flex items-center h-9 px-1.5 shrink-0">
|
||||
<ChevronDown size={ICON} class="flex-shrink-0" />
|
||||
</span>
|
||||
{:else}
|
||||
<Menubar>
|
||||
{#snippet children({ createMenu })}
|
||||
<Menu
|
||||
bind:this={workspaceMenu}
|
||||
{createMenu}
|
||||
usePointerDownOutside
|
||||
placement="bottom-start"
|
||||
class="h-auto"
|
||||
contentStyle="margin-left: {-nameWidth}px"
|
||||
>
|
||||
{#snippet triggr({ trigger })}
|
||||
<MeltButton
|
||||
meltElement={trigger}
|
||||
class="flex items-center h-9 px-1.5 rounded text-tertiary hover:bg-surface-hover hover:text-primary transition-colors"
|
||||
title="Switch workspace"
|
||||
>
|
||||
<ChevronDown size={ICON} class="flex-shrink-0" />
|
||||
</MeltButton>
|
||||
{/snippet}
|
||||
{#snippet children({ item: menuItem })}
|
||||
<WorkspacePickerBody
|
||||
item={menuItem}
|
||||
collapseFamilies={actingWorkspaceId != undefined}
|
||||
closeMenu={() => workspaceMenu?.close()}
|
||||
/>
|
||||
{/snippet}
|
||||
</Menu>
|
||||
{/snippet}
|
||||
</Menubar>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet trail(f: TrailStage, measure: boolean)}
|
||||
{@const scopeTitle = showFork ? `${familyName} / ${scopeName}` : familyName}
|
||||
{#if !nameOnly}
|
||||
<!-- One part for where the user is: the name of the workspace they are standing in — a fork
|
||||
included, in its own colour — leading home, and one chevron whose picker changes it. The
|
||||
picker lists the families and expands one to reach its forks, so a fork needs no part of
|
||||
its own here. -->
|
||||
<!-- `shrink-[999]`: the whole trail can give width back, and this part gives it first. Flex
|
||||
takes it from the parts in proportion to what they ask for, so a factor this far above
|
||||
the rest means the names here are eaten down to their icons before the page's own name
|
||||
loses a character. -->
|
||||
<div class="flex items-center min-w-0 shrink-[999]">
|
||||
included, in its own colour — leading home, and one chevron whose picker changes it. The
|
||||
picker lists the families and expands one to reach its forks, so a fork needs no part of
|
||||
its own here. Its parts never shrink on their own: the bar drops them whole and caps the
|
||||
names left, in the order pageHeaderStages sets. -->
|
||||
<div class="flex items-center shrink-0">
|
||||
<!-- No hover fill on the name: the chevron beside it is the thing that lights up, and two
|
||||
boxes reacting to one pass of the pointer read as two controls fighting. -->
|
||||
boxes reacting to one pass of the pointer read as two controls fighting. -->
|
||||
<!-- bind:clientWidth: the menu hangs from the chevron, so it is shifted back by the width of
|
||||
the name to line its left edge up with the disc. -->
|
||||
<div class="flex items-center min-w-0" bind:clientWidth={nameWidth}>
|
||||
<a
|
||||
href={homeHref}
|
||||
class={scopeChipClass}
|
||||
title={showFork ? `${familyName} / ${scopeName}` : familyName}
|
||||
the name to line its left edge up with the disc. -->
|
||||
<div
|
||||
class="flex items-center"
|
||||
bind:clientWidth={() => nameWidth, (w) => !measure && (nameWidth = w)}
|
||||
>
|
||||
<svelte:element
|
||||
this={measure ? 'span' : 'a'}
|
||||
href={measure ? undefined : homeHref}
|
||||
class={f.wsButton ? 'flex items-center gap-1 pl-1 py-0.5 rounded' : scopeChipClass}
|
||||
title={scopeTitle}
|
||||
>
|
||||
<!-- The family's name goes before the fork's: the fork is where the work happens, the
|
||||
family is which product it is part of, and of the two that is the one a reader can
|
||||
do without. Both truncate to nothing rather than disappearing at a breakpoint —
|
||||
what is left then is what the part cannot be read without, the disc in the
|
||||
workspace's own colour, the fork mark and the environment. The hover title
|
||||
carries the names whole, and the picker beside it names them all. -->
|
||||
<span class="flex items-center gap-1 min-w-0 shrink-[999]">
|
||||
<BreadcrumbItemContent label={familyName} icon={workspaceDisc} />
|
||||
</span>
|
||||
{#if showFork}
|
||||
{@render slash('mx-0.5')}
|
||||
<span class={forkChipClass} style={forkAccent}>
|
||||
<GitFork size={ICON} class="flex-shrink-0" />
|
||||
<span class="truncate">{scopeName}</span>
|
||||
{@render envBadgeMark()}
|
||||
{#if f.wsButton}
|
||||
<!-- Down to its disc, the part still leads home: the fork it stands in rides on the
|
||||
disc as a mark, and the environment stays beside it. -->
|
||||
<span class="relative flex shrink-0">
|
||||
{@render workspaceDisc()}
|
||||
{#if showFork}
|
||||
<span
|
||||
class={twMerge(
|
||||
'absolute -right-1 -bottom-0.5 size-3.5 rounded-full grid place-items-center ring-2 ring-surface',
|
||||
forkAccent
|
||||
? 'bg-[color:var(--fork-accent-badge)] text-white'
|
||||
: 'bg-surface-sunken text-primary'
|
||||
)}
|
||||
style={forkAccent}
|
||||
>
|
||||
<GitFork size={9} />
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
{:else}
|
||||
{@render envBadgeMark()}
|
||||
{:else}
|
||||
<!-- The family's name goes before the fork's: the fork is where the work happens,
|
||||
the family is which product it is part of, and of the two that is the one a
|
||||
reader can do without. Outside a fork it is the workspace's own name and stays
|
||||
until the part is down to its disc. -->
|
||||
<span class="flex items-center gap-1 shrink-0">
|
||||
{@render workspaceDisc()}
|
||||
{#if f.familyName || !showFork}
|
||||
<span class="truncate" style={capStyle('family', measure)}>{familyName}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if showFork}
|
||||
{@render slash('mx-0.5')}
|
||||
<span class={twMerge(forkChipClass, 'shrink-0')} style={forkAccent}>
|
||||
<GitFork size={ICON} class="flex-shrink-0" />
|
||||
{#if f.forkName}
|
||||
<span class="truncate" style={capStyle('fork', measure)}>{scopeName}</span>
|
||||
{/if}
|
||||
{@render envBadgeMark()}
|
||||
</span>
|
||||
{:else}
|
||||
{@render envBadgeMark()}
|
||||
{/if}
|
||||
{/if}
|
||||
</a>
|
||||
</svelte:element>
|
||||
</div>
|
||||
<Menubar>
|
||||
{#snippet children({ createMenu })}
|
||||
<Menu
|
||||
bind:this={workspaceMenu}
|
||||
{createMenu}
|
||||
usePointerDownOutside
|
||||
placement="bottom-start"
|
||||
class="h-auto"
|
||||
contentStyle="margin-left: {-nameWidth}px"
|
||||
>
|
||||
{#snippet triggr({ trigger })}
|
||||
<MeltButton
|
||||
meltElement={trigger}
|
||||
class="flex items-center h-9 px-1.5 rounded text-tertiary hover:bg-surface-hover hover:text-primary transition-colors"
|
||||
title="Switch workspace"
|
||||
>
|
||||
<ChevronDown size={ICON} class="flex-shrink-0" />
|
||||
</MeltButton>
|
||||
{/snippet}
|
||||
{#snippet children({ item: menuItem })}
|
||||
<WorkspacePickerBody
|
||||
item={menuItem}
|
||||
collapseFamilies={actingWorkspaceId != undefined}
|
||||
closeMenu={() => workspaceMenu?.close()}
|
||||
/>
|
||||
{/snippet}
|
||||
</Menu>
|
||||
{/snippet}
|
||||
</Menubar>
|
||||
{@render workspaceMenu_(measure)}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -324,6 +378,7 @@ not this component) — so the lead can run the whole line.
|
||||
|
||||
{#if item}
|
||||
{#if item.path}
|
||||
{@const shown = shownPathLevels(pathLevels, f)}
|
||||
<!-- The path whole, as one label rather than a row of pickers: it is what a person reads
|
||||
to know which item this is, and what they reach for to paste somewhere else. -->
|
||||
<div class="relative flex items-center min-w-0">
|
||||
@@ -334,19 +389,28 @@ not this component) — so the lead can run the whole line.
|
||||
{:else}
|
||||
{@render folderIcon()}
|
||||
{/if}
|
||||
{#each pathLevels as level, i (i)}
|
||||
{#each shown as level, i (i)}
|
||||
{#if i > 0}{@render slash()}{/if}
|
||||
<!-- The scopes above the name absorb the squeeze, the name itself last: a path is
|
||||
read from its end, and which folder a thing is in is answerable from the
|
||||
picker while the thing's own name is not. -->
|
||||
<span
|
||||
class={i === pathLevels.length - 1
|
||||
? 'truncate'
|
||||
: 'truncate min-w-0 shrink-[999] max-w-[8rem]'}>{level}</span
|
||||
>
|
||||
{#if i === shown.length - 1}
|
||||
<span class="truncate shrink-0" style={capStyle('name', measure)}>{level}</span>
|
||||
{:else if level === '…' && !f.middleLevels}
|
||||
<span class="shrink-0 text-tertiary">…</span>
|
||||
{:else}
|
||||
<!-- The folder or user gives width only once the name is down to a few
|
||||
characters: it is short and says whose thing this is, while a name long
|
||||
enough to need truncating still reads from its first words. -->
|
||||
<span
|
||||
class={twMerge(
|
||||
'truncate max-w-[8rem]',
|
||||
scopesGive && !measure ? 'min-w-0' : 'shrink-0'
|
||||
)}>{level}</span
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
{/snippet}
|
||||
{#if item.pathTrigger}
|
||||
{#if measure}
|
||||
<span class={pathSegment}>{@render pathLabel()}</span>
|
||||
{:else if item.pathTrigger}
|
||||
<!-- The page hangs its own rename off this segment, anchored here so the editor
|
||||
opens under the path rather than beside the summary. It wears the segment's
|
||||
own classes, so a clickable path reads the same as one that only copies. -->
|
||||
@@ -361,8 +425,8 @@ not this component) — so the lead can run the whole line.
|
||||
{@render pathLabel()}
|
||||
</button>
|
||||
{/if}
|
||||
{#if afterName}{@render afterName()}{/if}
|
||||
{#if pathCopied}
|
||||
{@render afterNameSlot(measure)}
|
||||
{#if pathCopied && !measure}
|
||||
<span
|
||||
class="absolute left-0 top-full mt-1 z-[6000] rounded-md border bg-surface px-2 py-1 text-2xs text-secondary shadow-md whitespace-nowrap"
|
||||
role="status"
|
||||
@@ -386,15 +450,21 @@ not this component) — so the lead can run the whole line.
|
||||
</span>
|
||||
{/if}
|
||||
{:else if section?.kind}
|
||||
<BreadcrumbSegment
|
||||
label={KIND_LABEL_LOWER[section.kind]}
|
||||
icon={sectionKindIcon}
|
||||
extraClass={twMerge(SEGMENT, LAST)}
|
||||
isCurrent
|
||||
initialHighlight={kindKey(section.kind)}
|
||||
initialScope={{ kind: section.kind }}
|
||||
onPick={() => {}}
|
||||
/>
|
||||
{#if measure}
|
||||
<span class={twMerge('inline-flex items-center px-1', SEGMENT, LAST)}>
|
||||
{@render sectionKindIcon()}<span>{KIND_LABEL_LOWER[section.kind]}</span>
|
||||
</span>
|
||||
{:else}
|
||||
<BreadcrumbSegment
|
||||
label={KIND_LABEL_LOWER[section.kind]}
|
||||
icon={sectionKindIcon}
|
||||
extraClass={twMerge(SEGMENT, LAST)}
|
||||
isCurrent
|
||||
initialHighlight={kindKey(section.kind)}
|
||||
initialScope={{ kind: section.kind }}
|
||||
onPick={() => {}}
|
||||
/>
|
||||
{/if}
|
||||
{:else if section}
|
||||
{#if section.parent}
|
||||
<!-- Gives width back with the workspace part, before the section's own name does. -->
|
||||
@@ -405,30 +475,87 @@ not this component) — so the lead can run the whole line.
|
||||
</span>
|
||||
{@render slash()}
|
||||
{/if}
|
||||
<!-- A section's own widget lays out as a row: its title, and whatever belongs with it. -->
|
||||
<span
|
||||
class={twMerge(
|
||||
SEGMENT,
|
||||
LAST,
|
||||
'hover:bg-transparent hover:text-emphasis',
|
||||
section.content ? '' : 'truncate'
|
||||
)}
|
||||
aria-current="page"
|
||||
>
|
||||
{#if section.content}{@render section.content()}{:else}{section.label}{/if}
|
||||
</span>
|
||||
{#if afterName}{@render afterName()}{/if}
|
||||
{#if section.content && measure}
|
||||
<span class="shrink-0" style="width: {sectionContentWidth}px"></span>
|
||||
{:else}
|
||||
<!-- A section's own widget lays out as a row: its title, and whatever belongs with it. -->
|
||||
<span
|
||||
class={twMerge(
|
||||
SEGMENT,
|
||||
LAST,
|
||||
'hover:bg-transparent hover:text-emphasis',
|
||||
section.content ? '' : 'truncate'
|
||||
)}
|
||||
aria-current={measure ? undefined : 'page'}
|
||||
bind:clientWidth={
|
||||
() => sectionContentWidth, (w) => !measure && section.content && (sectionContentWidth = w)
|
||||
}
|
||||
>
|
||||
{#if section.content}{@render section.content()}{:else}{section.label}{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{@render afterNameSlot(measure)}
|
||||
{:else if routePage}
|
||||
{@const RouteIcon = routePage.icon}
|
||||
<!-- shrink-0: a page's own name is short and is the one part of the trail worth keeping
|
||||
whole, so the workspace and fork names give way first when the bar is full. -->
|
||||
<span
|
||||
class={twMerge(SEGMENT, LAST, 'shrink-0 hover:bg-transparent hover:text-emphasis')}
|
||||
aria-current="page"
|
||||
aria-current={measure ? undefined : 'page'}
|
||||
>
|
||||
{#if RouteIcon}<RouteIcon size={ICON} class="flex-shrink-0 text-tertiary" />{/if}
|
||||
<span class="truncate">{routePage.label}</span>
|
||||
</span>
|
||||
{#if afterName}{@render afterName()}{/if}
|
||||
{@render afterNameSlot(measure)}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet afterNameSlot(measure: boolean)}
|
||||
{#if afterName}
|
||||
{#if measure}
|
||||
<span class="shrink-0" style="width: {afterNameWidth}px"></span>
|
||||
{:else}
|
||||
<span class="flex items-center shrink-0" bind:clientWidth={afterNameWidth}>
|
||||
{@render afterName()}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<nav
|
||||
aria-label="Breadcrumb"
|
||||
class="flex items-center gap-1.5 min-w-0 text-xs font-normal text-primary"
|
||||
>
|
||||
{@render trail(stage, false)}
|
||||
</nav>
|
||||
|
||||
{#if measures}
|
||||
<!-- The trail at every stage, each name at its floor, for the bar to choose a stage with; and
|
||||
each name whole, for it to cap them with. Same snippet, same classes, so the widths are the
|
||||
ones the trail on screen would take. -->
|
||||
<div class="absolute -left-[99999px] top-0 invisible" aria-hidden="true" inert>
|
||||
{#each BAR_STAGES as s, i (i)}
|
||||
<nav
|
||||
class="flex items-center gap-1.5 w-max text-xs font-normal text-primary"
|
||||
bind:clientWidth={measures.trail[i]}
|
||||
>
|
||||
{@render trail(s, true)}
|
||||
</nav>
|
||||
{/each}
|
||||
<!-- Always rendered, empty where the page has no such part: a width of 0 is how the bar
|
||||
knows the part is not there. -->
|
||||
<span
|
||||
class={twMerge(scopeChipClass, 'inline-block whitespace-nowrap p-0')}
|
||||
bind:clientWidth={measures.family}>{nameOnly ? '' : familyName}</span
|
||||
>
|
||||
<span
|
||||
class={twMerge(forkChipClass, 'inline-block whitespace-nowrap p-0')}
|
||||
bind:clientWidth={measures.fork}>{!nameOnly && showFork ? scopeName : ''}</span
|
||||
>
|
||||
<span
|
||||
class={twMerge('inline-block whitespace-nowrap text-xs', itemIsLast ? LAST : LEAD)}
|
||||
bind:clientWidth={measures.name}
|
||||
>{item?.path ? (pathLevels[pathLevels.length - 1] ?? '') : ''}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -16,6 +16,9 @@ The row's height matches the sidebar's own header row, so the two read as one ba
|
||||
import ContextBridge from './ContextBridge.svelte'
|
||||
import { afterNavigate } from '$app/navigation'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { onDestroy } from 'svelte'
|
||||
import type { Attachment } from 'svelte/attachments'
|
||||
import { BAR_STAGES, FLOOR, fitBar, type BarFit, type TrailMeasures } from './pageHeaderStages'
|
||||
|
||||
let {
|
||||
navHidden = false,
|
||||
@@ -34,14 +37,71 @@ The row's height matches the sidebar's own header row, so the two read as one ba
|
||||
const section = $derived(content?.section)
|
||||
const hasSummary = $derived(!!item && !!(item.summaryContent || item.summary))
|
||||
|
||||
// Below this the trail holds a tighter cap on the path it draws. The names in it truncate on
|
||||
// their own at any width (see NavBreadcrumb), so this is not a breakpoint the look changes at —
|
||||
// it only stops a long path from claiming a third of a short bar before the squeeze starts.
|
||||
const NARROW_BAR = 1000
|
||||
const narrow = $derived(pageHeader.barWidth > 0 && pageHeader.barWidth < NARROW_BAR)
|
||||
/** A phone's bar: the trail gives way before the page's buttons do (see the markup). */
|
||||
/** A phone's bar: a tighter gutter around the same row. */
|
||||
const phone = $derived(pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR)
|
||||
|
||||
// What the bar drops, and when the page's actions fold, follows from the widths of what is in
|
||||
// it (see pageHeaderStages): measured here and in NavBreadcrumb, never guessed per page.
|
||||
/** `gap-1` between the bar's boxes. */
|
||||
const GAP = 4
|
||||
/** Actions that shrink or fill the bar have no width of their own to plan around: the trail
|
||||
* leaves them this much and they take whatever is left. */
|
||||
const FLEXIBLE_ACTIONS_RESERVE = 240
|
||||
|
||||
let trailMeasures = $state<TrailMeasures>({ trail: [], family: 0, fork: 0, name: 0 })
|
||||
/** The summary held at its floor, with the dot before it. */
|
||||
let summaryFloorWidth = $state(0)
|
||||
let summaryTextWidth = $state(0)
|
||||
/** A page's own summary control, last seen uncapped. */
|
||||
let summaryContentWidth = $state(0)
|
||||
let handleWidth = $state(0)
|
||||
/** The actions box at each fold level it has been seen at, for this page. */
|
||||
let actionWidths = $state<(number | undefined)[]>([])
|
||||
let actionsEpoch = $state(0)
|
||||
|
||||
const handleShown = $derived((navDetached.val || panelled) && !navHidden)
|
||||
// The bar's own padding, in px, as the classes on the row below set it.
|
||||
const padLeft = $derived(navHidden ? 16 : phone ? 4 : 8)
|
||||
const padRight = $derived(content?.actionsFill ? 0 : phone ? 8 : 16)
|
||||
const actionsRigid = $derived(!content?.actionsFlexible && !content?.actionsFill)
|
||||
const FULL: BarFit = { stage: 0, fold: 0, caps: {}, scopesGive: false }
|
||||
|
||||
const fit = $derived.by((): BarFit => {
|
||||
// Unmeasured counts as wide: the bar measures itself on mount, and starting narrow would fold
|
||||
// a page's buttons for one frame and pop them back out the next.
|
||||
if (pageHeader.barWidth === 0) return FULL
|
||||
return fitBar({
|
||||
barWidth: pageHeader.barWidth,
|
||||
chrome: padLeft + padRight + (handleShown ? handleWidth + GAP : 0),
|
||||
trail: trailMeasures.trail,
|
||||
summary: BAR_STAGES.map((s) => (hasSummary && s.summary ? GAP + summaryFloorWidth : 0)),
|
||||
actions:
|
||||
actions.length === 0 ? [0] : actionsRigid ? actionWidths : [GAP + FLEXIBLE_ACTIONS_RESERVE],
|
||||
folds: actions.length > 0 && actionsRigid ? (content?.actionFolds ?? 0) : 0,
|
||||
natural: {
|
||||
family: trailMeasures.family,
|
||||
fork: trailMeasures.fork,
|
||||
summary: hasSummary ? (item?.summaryContent ? summaryContentWidth : summaryTextWidth) : 0,
|
||||
name: trailMeasures.name
|
||||
}
|
||||
})
|
||||
})
|
||||
const stage = $derived(BAR_STAGES[fit.stage])
|
||||
onDestroy(pageHeader.provideActionFold(() => fit.fold))
|
||||
|
||||
// Re-attached whenever the fold level changes, so the first reading at a level is taken even
|
||||
// when the row comes out exactly as wide as it was at the last one.
|
||||
function measureActions(fold: number, _epoch: number): Attachment<HTMLElement> {
|
||||
return (node) => {
|
||||
const ro = new ResizeObserver(() => (actionWidths[fold] = GAP + node.offsetWidth))
|
||||
ro.observe(node)
|
||||
return () => ro.disconnect()
|
||||
}
|
||||
}
|
||||
// Until the row has been seen at the level the bar picked, it is laid out but not shown: the bar
|
||||
// may find it does not fit and fold it further, and that should not flash.
|
||||
const actionsUnmeasured = $derived(actionsRigid && actionWidths[fit.fold] === undefined)
|
||||
|
||||
// The band's edge is there to say "something is passing under me", so it is drawn only while
|
||||
// something is. Scroll events do not bubble, but they do capture, so one listener on the window
|
||||
// sees every box under the band — which matters because most pages scroll inside their own
|
||||
@@ -69,8 +129,14 @@ The row's height matches the sidebar's own header row, so the two read as one ba
|
||||
scrolledUnder = next
|
||||
}
|
||||
// A page left while scrolled would otherwise hand its edge to the next one, which may have
|
||||
// nothing to scroll at all.
|
||||
afterNavigate(() => (scrolledUnder = false))
|
||||
// nothing to scroll at all. Its actions' widths go with it.
|
||||
afterNavigate(({ from, to }) => {
|
||||
scrolledUnder = false
|
||||
if (from?.url.pathname !== to?.url.pathname) {
|
||||
actionWidths = []
|
||||
actionsEpoch++
|
||||
}
|
||||
})
|
||||
|
||||
const separator = $derived(pageHeader.content?.separator ?? 'onScroll')
|
||||
const showEdge = $derived(separator === 'always' || (separator === 'onScroll' && scrolledUnder))
|
||||
@@ -112,6 +178,7 @@ The row's height matches the sidebar's own header row, so the two read as one ba
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
data-nav-handle
|
||||
bind:clientWidth={handleWidth}
|
||||
onmouseenter={() => navHandleSlot.open()}
|
||||
onmouseleave={() => navHandleSlot.scheduleClose()}
|
||||
>
|
||||
@@ -128,26 +195,13 @@ The row's height matches the sidebar's own header row, so the two read as one ba
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- The breadcrumb yields width more slowly than the page's controls (shrink-[0.3]) but it does
|
||||
yield, at every width: the trail truncates from its start, so the squeeze is spent on the
|
||||
workspace and the folders before it ever reaches the name of the thing the page is about.
|
||||
The 5rem floor is what is left when every name in it has gone — the workspace disc, the
|
||||
fork mark and the environment badge, which is as small as the part can be and still say
|
||||
where the user is.
|
||||
<!-- The breadcrumb's parts are rigid and the bar sizes them (see `fit`): it drops whole parts
|
||||
and caps the names left, so this box only shrinks past what they add up to on a bar too
|
||||
narrow for even the last stage.
|
||||
An embed (`navHidden`) keeps the page's own name but drops the workspace part around it:
|
||||
the trail would offer to navigate the host's workspace, while the name is what says which
|
||||
page the controls beside it belong to.
|
||||
The 0.3 only holds against a summary or an actions box that shrinks too: alone, a factor
|
||||
sum below 1 makes flex hand out only that share of the overflow, and the bar overflows
|
||||
instead. -->
|
||||
<div
|
||||
class={twMerge(
|
||||
'flex shrink-[0.3] min-w-[5rem]',
|
||||
!hasSummary &&
|
||||
(actions.length === 0 || (phone && !content?.actionsFlexible && !content?.actionsFill)) &&
|
||||
'shrink'
|
||||
)}
|
||||
>
|
||||
page the controls beside it belong to. -->
|
||||
<div class="flex min-w-0">
|
||||
<!-- Bridged like the actions below: what a page hangs off its own name renders here, out of
|
||||
the tree that named it, and the rename editor in there asks that tree who the acting user
|
||||
is before it offers to rename anything. -->
|
||||
@@ -162,7 +216,10 @@ The row's height matches the sidebar's own header row, so the two read as one ba
|
||||
<NavBreadcrumb
|
||||
{item}
|
||||
{section}
|
||||
{narrow}
|
||||
{stage}
|
||||
caps={fit.caps}
|
||||
scopesGive={fit.scopesGive}
|
||||
bind:measures={trailMeasures}
|
||||
nameOnly={navHidden}
|
||||
afterName={content?.afterName}
|
||||
actingWorkspaceId={content?.actingWorkspaceId}
|
||||
@@ -172,36 +229,54 @@ The row's height matches the sidebar's own header row, so the two read as one ba
|
||||
</svelte:boundary>
|
||||
</div>
|
||||
|
||||
{#if item && hasSummary}
|
||||
{#if item && hasSummary && stage.summary}
|
||||
<!-- No kind icon: the page below is the item, and saying "this is a flow" above a flow
|
||||
editor tells the reader what they can already see. -->
|
||||
<div class={twMerge('flex items-center gap-1 min-w-0', phone && 'shrink')}>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<!-- A dot rather than a slash: the summary names the same item the path just located, it
|
||||
is not another level of it. Inside this box so that it narrows with the summary. -->
|
||||
is not another level of it. -->
|
||||
<span class="shrink-0 text-hint/40 text-xs px-0.5" aria-hidden="true">·</span>
|
||||
{#if item.summaryContent}
|
||||
<svelte:boundary onerror={(e) => console.error('page header: summary failed to render', e)}>
|
||||
{#key content?.contexts}
|
||||
<ContextBridge contexts={content?.contexts}>
|
||||
{@render item.summaryContent()}
|
||||
</ContextBridge>
|
||||
{/key}
|
||||
</svelte:boundary>
|
||||
<!-- Read only while uncapped: capped, the box says how wide the bar let it be, not how
|
||||
wide the page's control wants to be. -->
|
||||
<div
|
||||
class="flex min-w-0 overflow-hidden"
|
||||
style={fit.caps.summary === undefined ? undefined : `max-width: ${fit.caps.summary}px`}
|
||||
bind:clientWidth={
|
||||
() => summaryContentWidth,
|
||||
(w) => fit.caps.summary === undefined && (summaryContentWidth = w)
|
||||
}
|
||||
>
|
||||
<svelte:boundary
|
||||
onerror={(e) => console.error('page header: summary failed to render', e)}
|
||||
>
|
||||
{#key content?.contexts}
|
||||
<ContextBridge contexts={content?.contexts}>
|
||||
{@render item.summaryContent()}
|
||||
</ContextBridge>
|
||||
{/key}
|
||||
</svelte:boundary>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="min-w-0 truncate text-xs font-medium text-emphasis">{item.summary}</span>
|
||||
<span
|
||||
class="truncate text-xs font-medium text-emphasis"
|
||||
style={fit.caps.summary === undefined ? undefined : `max-width: ${fit.caps.summary}px`}
|
||||
>{item.summary}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if actions.length > 0}
|
||||
<!-- min-w-0, not shrink-0: a page whose actions are a filter row (Runs) puts a control in
|
||||
here that can give width back, and it can only do that if this box may shrink. A phone
|
||||
only tightens the gutter — pages fold their own buttons into a menu at that width, and
|
||||
a box that refused to shrink would make the one flexible field take its max instead. -->
|
||||
<!-- A plain row of buttons holds its width (shrink-0): squeezing it only pushes buttons out
|
||||
of the bar, so the bar makes room by dropping breadcrumb parts and then by asking the
|
||||
page to fold (`actionFolds`). Flexible actions (the Runs filter row) are a control that
|
||||
can give width back, which it can only do if this box may shrink. -->
|
||||
<!-- `actionsFill`: the actions are a surface rather than a row, so they take the rest of the
|
||||
bar and run to its right edge, and they stretch its full height because that surface
|
||||
sits on the bottom edge. -->
|
||||
<div
|
||||
{@attach measureActions(fit.fold, actionsEpoch)}
|
||||
class={twMerge(
|
||||
'flex',
|
||||
// pl-4: the gap between the breadcrumb and the filling surface is bare bar, so it
|
||||
@@ -209,8 +284,9 @@ The row's height matches the sidebar's own header row, so the two read as one ba
|
||||
content?.actionsFill
|
||||
? 'flex-1 min-w-0 self-stretch items-stretch pl-4'
|
||||
: 'ml-auto items-center',
|
||||
!content?.actionsFill && (phone && !content?.actionsFlexible ? 'shrink-0' : 'min-w-0'),
|
||||
!content?.actionsFill && (phone ? 'gap-1 pl-1' : 'gap-2 pl-4')
|
||||
!content?.actionsFill && (actionsRigid ? 'shrink-0' : 'min-w-0'),
|
||||
!content?.actionsFill && (phone ? 'gap-1 pl-1' : 'gap-2 pl-4'),
|
||||
actionsUnmeasured && 'invisible'
|
||||
)}
|
||||
>
|
||||
{#each actions as entry, i (i)}
|
||||
@@ -224,4 +300,25 @@ The row's height matches the sidebar's own header row, so the two read as one ba
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if item && hasSummary}
|
||||
<!-- The summary at its floor, which is what a stage that keeps it needs; and whole, which is
|
||||
what the bar caps it from. A page's own control stands in at the width last seen. -->
|
||||
<div class="absolute -left-[99999px] top-0 invisible" aria-hidden="true" inert>
|
||||
<div class="flex items-center gap-1 w-max" bind:clientWidth={summaryFloorWidth}>
|
||||
<span class="shrink-0 text-hint/40 text-xs px-0.5">·</span>
|
||||
{#if item.summaryContent}
|
||||
<span style="width: {Math.min(summaryContentWidth, FLOOR.summary)}px"></span>
|
||||
{:else}
|
||||
<span class="truncate text-xs font-medium" style="max-width: {FLOOR.summary}px"
|
||||
>{item.summary}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<span
|
||||
class="inline-block whitespace-nowrap text-xs font-medium"
|
||||
bind:clientWidth={summaryTextWidth}>{item.summary ?? ''}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,7 @@ the header, so nesting is decided by the caller rather than guessed from context
|
||||
actionsOrder,
|
||||
actionsFlexible,
|
||||
actionsFill,
|
||||
actionFolds,
|
||||
separator,
|
||||
contexts,
|
||||
actingWorkspaceId,
|
||||
@@ -33,6 +34,7 @@ the header, so nesting is decided by the caller rather than guessed from context
|
||||
actionsOrder,
|
||||
actionsFlexible,
|
||||
actionsFill,
|
||||
actionFolds,
|
||||
separator,
|
||||
contexts,
|
||||
actingWorkspaceId,
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
import EditorHeader from './EditorHeader.svelte'
|
||||
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
|
||||
import PathEditPopover from '$lib/components/PathEditPopover.svelte'
|
||||
import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte'
|
||||
import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte'
|
||||
import ScriptSettingsBadges from './ScriptSettingsBadges.svelte'
|
||||
import Badge from './common/badge/Badge.svelte'
|
||||
import Modal from './common/modal/Modal.svelte'
|
||||
@@ -206,19 +206,14 @@
|
||||
let pathSnapshot = $state<string | undefined>(undefined)
|
||||
|
||||
let topbarWidth = $state(0)
|
||||
// In the page header the buttons share the row with the breadcrumb, so they collapse sooner:
|
||||
// the trail and the summary take ~560px before either truncates, and the full button group is
|
||||
// ~515px — about 1100px between them, plus slack for a longer path than most.
|
||||
const compactBelow = $derived(ownsPageHeader ? 1200 : 900)
|
||||
const compactTopbar = $derived.by(() => {
|
||||
const w = ownsPageHeader ? pageHeader.barWidth : topbarWidth
|
||||
return w > 0 && w < compactBelow
|
||||
})
|
||||
// A phone's bar holds the trail and one button. Diff and Settings join the menu that compact
|
||||
// already shows, and Deploy stays out: it is what this page is for.
|
||||
const phoneTopbar = $derived(
|
||||
ownsPageHeader && pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR
|
||||
// In the page header the bar folds the buttons (`actionFolds` below), once its breadcrumb has
|
||||
// given all it can; anywhere else this editor's own top bar is theirs alone.
|
||||
const compactTopbar = $derived(
|
||||
ownsPageHeader ? pageHeader.actionFold >= 1 : topbarWidth > 0 && topbarWidth < 900
|
||||
)
|
||||
// The second fold holds the trail and one button. Diff and Settings join the menu that compact
|
||||
// already shows, and Deploy stays out: it is what this page is for.
|
||||
const phoneTopbar = $derived(ownsPageHeader && pageHeader.actionFold >= 2)
|
||||
|
||||
// The workspace this editor operates on: deploy, save-draft, trigger loading
|
||||
// and the AutosaveIndicator all target it. Falls back to the full-page
|
||||
@@ -2326,6 +2321,7 @@
|
||||
}}
|
||||
afterName={scriptMarks}
|
||||
actions={scriptHeaderActions}
|
||||
actionFolds={2}
|
||||
separator="none"
|
||||
/>
|
||||
{:else}
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
import EditorHeader from '$lib/components/EditorHeader.svelte'
|
||||
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
|
||||
import PathEditPopover from '$lib/components/PathEditPopover.svelte'
|
||||
import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte'
|
||||
import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte'
|
||||
import AutosaveIndicator from '$lib/components/AutosaveIndicator.svelte'
|
||||
import { editPathFor } from '$lib/components/workspacePicker'
|
||||
import { invalidateWorkspacePaths } from '$lib/components/PathNameAutocomplete.svelte'
|
||||
@@ -261,24 +261,16 @@
|
||||
|
||||
// Top-bar responsive collapse — container width, not viewport.
|
||||
let topbarWidth = $state(0)
|
||||
// In the page header the buttons share the row with the breadcrumb, so they shed the canvas
|
||||
// toggles and fold Debug runs into the menu sooner: the whole group is ~840px and the trail
|
||||
// with the summary takes ~430px of the same row.
|
||||
// Measured on the band rather than estimated: with the canvas toggles shown the actions alone
|
||||
// are 826-911px and the trail with the summary another ~500, so the row overflows the bar at
|
||||
// every width up to about 1470 — 1350 was not high enough either. Below this the toggles stand
|
||||
// down and the menu carries all three, which is what keeps them reachable rather than merely
|
||||
// out of the way.
|
||||
const compactBelow = $derived(ownsPageHeader ? 1500 : 720)
|
||||
const compactTopbar = $derived.by(() => {
|
||||
const w = ownsPageHeader ? pageHeader.barWidth : topbarWidth
|
||||
return w > 0 && w < compactBelow
|
||||
})
|
||||
// A phone's bar holds the trail and Deploy. Which panels are open, the export and the
|
||||
// editor/preview switch join the menu compact already shows.
|
||||
const phoneTopbar = $derived(
|
||||
ownsPageHeader && pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR
|
||||
// In the page header the bar folds the buttons (`actionFolds` below), once its breadcrumb has
|
||||
// given all it can; anywhere else this editor's own top bar is theirs alone. Folded, the canvas
|
||||
// toggles stand down and the menu carries them and Debug runs, which keeps them reachable
|
||||
// rather than merely out of the way.
|
||||
const compactTopbar = $derived(
|
||||
ownsPageHeader ? pageHeader.actionFold >= 1 : topbarWidth > 0 && topbarWidth < 720
|
||||
)
|
||||
// The second fold holds the trail and Deploy. Which panels are open, the export and the
|
||||
// editor/preview switch join the menu compact already shows.
|
||||
const phoneTopbar = $derived(ownsPageHeader && pageHeader.actionFold >= 2)
|
||||
|
||||
function closeSaveDrawer() {
|
||||
saveDrawerOpen = false
|
||||
@@ -1014,6 +1006,7 @@
|
||||
pathTrigger: appPathTrigger
|
||||
}}
|
||||
actions={appHeaderActions}
|
||||
actionFolds={2}
|
||||
contexts={headerContexts}
|
||||
separator="always"
|
||||
/>
|
||||
|
||||
@@ -100,14 +100,9 @@
|
||||
|
||||
// Two reasons the row can be too tight, and either one collapses it. The host knows when its
|
||||
// own box is narrow while the window is not — a session's preview panel — and says so through
|
||||
// `wide`. The band knows its own width, which the host cannot see: these buttons share that row
|
||||
// with the breadcrumb, and the trail and summary want ~580px against the set's ~530. Unmeasured
|
||||
// (0) counts as wide, because the bar measures itself on mount and starting narrow would pop
|
||||
// the buttons out of the menu a frame later.
|
||||
// Measured on a flow's page: a 1192px bar leaves the last button 4px past the edge, so the row
|
||||
// has to fold before that rather than at the ~1150 the two parts add up to on paper.
|
||||
const COLLAPSE_BELOW = 1250
|
||||
const roomInBar = $derived(pageHeader.barWidth === 0 || pageHeader.barWidth >= COLLAPSE_BELOW)
|
||||
// `wide`. The band knows what its breadcrumb leaves, which the host cannot see, and folds the
|
||||
// row once the breadcrumb has given all it can (`actionFolds` below).
|
||||
const roomInBar = $derived(!ownsPageHeader || pageHeader.actionFold === 0)
|
||||
const wideRow = $derived(wide && roomInBar)
|
||||
|
||||
const barButtons = $derived(wideRow ? mainButtons : mainButtons.filter((b) => !b.narrow))
|
||||
@@ -278,6 +273,7 @@
|
||||
pathTrigger: onSaved ? pathTrigger : undefined
|
||||
}}
|
||||
{actions}
|
||||
actionFolds={1}
|
||||
contexts={headerContexts}
|
||||
separator="always"
|
||||
/>
|
||||
|
||||
@@ -52,6 +52,11 @@ export type PageHeaderContent = {
|
||||
* controls share one shape that opens into the page below. The bar gives up its right padding
|
||||
* to it, since the surface is what meets the edge. */
|
||||
actionsFill?: boolean
|
||||
/** How many levels past the full row these actions can fold through, each a narrower row than
|
||||
* the last. The bar picks the level from the room its breadcrumb leaves once it has dropped
|
||||
* 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
|
||||
/** Rendered right after the page's name in the breadcrumb: a mark that belongs to the name,
|
||||
* like a documentation tooltip, or a control that acts on the thing named — an app's Edit,
|
||||
* which at the far end of the bar would be a journey away from what it edits. */
|
||||
@@ -91,10 +96,9 @@ let entries = $state<{ id: number; get: () => PageHeaderContent }[]>([])
|
||||
let nextId = 0
|
||||
|
||||
/**
|
||||
* Below this bar width a page keeps one button — the thing the page is for — and folds the rest
|
||||
* into the menu it already shows when compact. A phone in portrait is ~390px of bar and a tablet
|
||||
* in portrait ~720; the breadcrumb with its workspace disc takes ~150 of either, which is why the
|
||||
* line sits above the tablet rather than between the two.
|
||||
* A phone's bar: a tighter gutter, and the width below which controls that cannot fold through
|
||||
* `actionFolds` — a flexible filter row, a banner — slim down. A phone in portrait is ~390px of
|
||||
* bar and a tablet in portrait ~720, which is why the line sits above the tablet.
|
||||
*/
|
||||
export const PHONE_BAR = 800
|
||||
|
||||
@@ -112,6 +116,9 @@ export const BAR_HEIGHT_PX = 48
|
||||
// share that row with the breadcrumb, so it is the width they crowd against — not the window's,
|
||||
// which the sidebar and a page's side panel both take from.
|
||||
let barWidth = $state(0)
|
||||
// The bar owns the fold level: it is derived from the bar's own measurements, so the bar hands
|
||||
// over a getter rather than writing a copy that an effect would have to keep in step.
|
||||
let actionFoldSource = $state<(() => number) | undefined>(undefined)
|
||||
|
||||
/**
|
||||
* One registration's content, or undefined while it cannot answer.
|
||||
@@ -151,6 +158,7 @@ export const pageHeader = {
|
||||
if (c.barRightInset !== undefined) merged.barRightInset = c.barRightInset
|
||||
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.separator !== undefined) merged.separator = c.separator
|
||||
}
|
||||
return merged
|
||||
@@ -176,6 +184,17 @@ export const pageHeader = {
|
||||
setBarWidth(w: number) {
|
||||
barWidth = w
|
||||
},
|
||||
/** How far the page's actions are folded: 0 for the full row, up to its `actionFolds`. */
|
||||
get actionFold(): number {
|
||||
return actionFoldSource?.() ?? 0
|
||||
},
|
||||
/** Called by the bar once; returns the release to call when the bar goes away. */
|
||||
provideActionFold(get: () => number): () => void {
|
||||
actionFoldSource = get
|
||||
return () => {
|
||||
if (actionFoldSource === get) actionFoldSource = undefined
|
||||
}
|
||||
},
|
||||
/** Registers on mount and returns the id to release on destroy. */
|
||||
register(get: () => PageHeaderContent): number {
|
||||
const id = nextId++
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BAR_STAGES, fitBar, type BarMeasure } from './pageHeaderStages'
|
||||
|
||||
// Each stage 50px narrower than the last, so the bar width alone says which one fits.
|
||||
const trail = BAR_STAGES.map((_, i) => 600 - i * 50)
|
||||
const base: BarMeasure = {
|
||||
barWidth: 2000,
|
||||
chrome: 0,
|
||||
trail,
|
||||
summary: BAR_STAGES.map(() => 0),
|
||||
actions: [300, 200, 100],
|
||||
folds: 2,
|
||||
natural: { family: 0, fork: 0, summary: 0, name: 0 }
|
||||
}
|
||||
|
||||
describe('fitBar', () => {
|
||||
it('picks the first stage that fits beside the full row of actions', () => {
|
||||
expect(fitBar({ ...base, barWidth: 900 }).stage).toBe(0)
|
||||
expect(fitBar({ ...base, barWidth: 800 })).toMatchObject({ stage: 2, fold: 0 })
|
||||
})
|
||||
|
||||
it('folds the actions only once the breadcrumb is at its last stage, and keeps it there', () => {
|
||||
// 350 + 300 overflows a 600px bar; 350 + 200 fits. Stage 0 would fit beside the folded row
|
||||
// too, but the parts a narrowing bar dropped do not come back when the buttons fold.
|
||||
expect(fitBar({ ...base, barWidth: 600 })).toMatchObject({ stage: 5, fold: 1 })
|
||||
expect(fitBar({ ...base, barWidth: 400 })).toMatchObject({ stage: 5, fold: 2 })
|
||||
})
|
||||
|
||||
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.
|
||||
const unseen = { ...base, barWidth: 600, actions: [undefined, undefined, 100] }
|
||||
expect(fitBar(unseen).fold).toBe(0)
|
||||
expect(fitBar({ ...unseen, actions: [300, undefined, 100] }).fold).toBe(1)
|
||||
})
|
||||
|
||||
it('takes the squeeze from the summary first, then the names, the item name last', () => {
|
||||
const fit = fitBar({
|
||||
...base,
|
||||
barWidth: 400,
|
||||
actions: [0],
|
||||
folds: 0,
|
||||
trail: trail.map((w) => w - 400),
|
||||
summary: BAR_STAGES.map((s) => (s.summary ? 100 : 0)),
|
||||
natural: { family: 0, fork: 0, summary: 300, name: 300 }
|
||||
})
|
||||
// Stage 0 at its floors is 300px; whole, the summary and the name add 204 and 140, so 244
|
||||
// has to go: all 204 the summary has above its floor, then 40 from the name.
|
||||
expect(fit).toMatchObject({ stage: 0, caps: { summary: 96, name: 260 }, scopesGive: false })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* How the page header gives up width. Whole parts drop in a fixed order, and the bar shows the
|
||||
* first stage whose parts, each held at its floor width, fit beside the page's actions. Which
|
||||
* stage that is depends on the names in the bar rather than on a pixel breakpoint. Past the last
|
||||
* stage the page's actions fold, one level at a time; past their last level the item's name
|
||||
* truncates, then the folders above it.
|
||||
*/
|
||||
|
||||
/** What the breadcrumb itself drops. */
|
||||
export type TrailStage = {
|
||||
/** Inside a fork, the family's name before the fork's. Outside one it is the workspace's own
|
||||
* name and stays until `wsButton`. */
|
||||
familyName: boolean
|
||||
forkName: boolean
|
||||
/** The workspace part as its disc alone, still leading home, beside its picker's chevron. */
|
||||
wsButton: boolean
|
||||
/** The folders between the first one and the name; collapsed, they read as `…`. */
|
||||
middleLevels: boolean
|
||||
}
|
||||
|
||||
export type BarStage = TrailStage & { summary: boolean; label: string }
|
||||
|
||||
export const BAR_STAGES: BarStage[] = [
|
||||
{
|
||||
label: 'Full',
|
||||
familyName: true,
|
||||
summary: true,
|
||||
forkName: true,
|
||||
wsButton: false,
|
||||
middleLevels: true
|
||||
},
|
||||
{
|
||||
label: 'Family name dropped',
|
||||
familyName: false,
|
||||
summary: true,
|
||||
forkName: true,
|
||||
wsButton: false,
|
||||
middleLevels: true
|
||||
},
|
||||
{
|
||||
label: 'Summary dropped',
|
||||
familyName: false,
|
||||
summary: false,
|
||||
forkName: true,
|
||||
wsButton: false,
|
||||
middleLevels: true
|
||||
},
|
||||
{
|
||||
label: 'Fork name dropped',
|
||||
familyName: false,
|
||||
summary: false,
|
||||
forkName: false,
|
||||
wsButton: false,
|
||||
middleLevels: true
|
||||
},
|
||||
{
|
||||
label: 'Workspace as its disc',
|
||||
familyName: false,
|
||||
summary: false,
|
||||
forkName: false,
|
||||
wsButton: true,
|
||||
middleLevels: true
|
||||
},
|
||||
{
|
||||
label: 'Middle folders collapsed',
|
||||
familyName: false,
|
||||
summary: false,
|
||||
forkName: false,
|
||||
wsButton: true,
|
||||
middleLevels: false
|
||||
}
|
||||
]
|
||||
|
||||
export type Part = 'family' | 'fork' | 'summary' | 'name'
|
||||
|
||||
/** The trail at each stage with its names at their floors, and each name's own width. */
|
||||
export type TrailMeasures = { trail: number[]; family: number; fork: number; name: number }
|
||||
|
||||
/** The width a part may truncate to before the next stage drops something whole. */
|
||||
export const FLOOR: Record<Part, number> = { family: 56, fork: 56, summary: 96, name: 160 }
|
||||
/** Past the last stage and fold, the name keeps this much before the folders give any. */
|
||||
export const SQUEEZED_NAME_FLOOR = 40
|
||||
|
||||
/** Middle folders collapsed to one `…`; the first folder and the name always stay. */
|
||||
export function shownPathLevels(levels: string[], stage: TrailStage): string[] {
|
||||
const scopes = levels.slice(0, -1)
|
||||
if (stage.middleLevels || scopes.length <= 1) return levels
|
||||
return [scopes[0], '…', levels[levels.length - 1]]
|
||||
}
|
||||
|
||||
/** Whether a part is on screen at a stage. A natural width of 0 means the page has no such part. */
|
||||
export function partVisible(part: Part, stage: BarStage, natural: Record<Part, number>): boolean {
|
||||
if (natural[part] <= 0) return false
|
||||
switch (part) {
|
||||
case 'summary':
|
||||
return stage.summary
|
||||
case 'family':
|
||||
// Outside a fork the family's name is the workspace's: it goes only with the whole part.
|
||||
return !stage.wsButton && (stage.familyName || natural.fork <= 0)
|
||||
case 'fork':
|
||||
return !stage.wsButton && stage.forkName
|
||||
case 'name':
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export type BarMeasure = {
|
||||
barWidth: number
|
||||
/** The bar's padding and the sidebar handle: what is there at every stage. */
|
||||
chrome: number
|
||||
/** Per stage: the breadcrumb with each name at its floor. */
|
||||
trail: number[]
|
||||
/** Per stage: the summary at its floor with the gap before it, 0 where it is dropped. */
|
||||
summary: number[]
|
||||
/** Per fold level: the actions with the gap before them, undefined for a level not yet seen. */
|
||||
actions: (number | undefined)[]
|
||||
/** How many levels past the full row the page's actions can fold through. */
|
||||
folds: number
|
||||
natural: Record<Part, number>
|
||||
}
|
||||
|
||||
export type BarFit = {
|
||||
stage: number
|
||||
fold: number
|
||||
/** Max widths for the parts that give width, in the order they give it. */
|
||||
caps: Partial<Record<Part, number>>
|
||||
/** Even the name at its squeezed floor does not fit: the folders above it truncate too. */
|
||||
scopesGive: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Flex shrinks every part in proportion, so however lopsided the factors, the name gives a few
|
||||
* pixels while the summary still has room, and a few pixels are an ellipsis. The parts are rigid
|
||||
* instead and the deficit is taken from them in a strict order, each down to its floor.
|
||||
*/
|
||||
export function fitBar(m: BarMeasure): BarFit {
|
||||
// A level not yet seen is assumed as narrow as the next one folded further: trying it is how it
|
||||
// gets measured, and once it has been, the bar never picks it again unless it fits.
|
||||
const actionsAt = (fold: number) => {
|
||||
for (let f = fold; f < m.actions.length; f++)
|
||||
if (m.actions[f] !== undefined) return m.actions[f]!
|
||||
return 0
|
||||
}
|
||||
const last = BAR_STAGES.length - 1
|
||||
const floorWidth = (s: number, f: number) =>
|
||||
m.chrome + (m.trail[s] ?? 0) + (m.summary[s] ?? 0) + actionsAt(f)
|
||||
|
||||
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
|
||||
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++) {
|
||||
if (floorWidth(last, f) <= m.barWidth) {
|
||||
fold = f
|
||||
break choose
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const at = BAR_STAGES[stage]
|
||||
const parts = (['summary', 'family', 'fork', 'name'] as Part[]).filter((p) =>
|
||||
partVisible(p, at, m.natural)
|
||||
)
|
||||
const slack = (p: Part) => Math.max(0, m.natural[p] - Math.min(m.natural[p], FLOOR[p]))
|
||||
// The stage was measured with its parts at their floors; this is its width with them whole.
|
||||
let deficit = floorWidth(stage, fold) + parts.reduce((s, p) => s + slack(p), 0) - m.barWidth
|
||||
const caps: Partial<Record<Part, number>> = {}
|
||||
const take = (p: Part, floor: number) => {
|
||||
if (deficit <= 0 || !parts.includes(p)) return
|
||||
const current = caps[p] ?? m.natural[p]
|
||||
const give = Math.min(deficit, Math.max(0, current - Math.min(m.natural[p], floor)))
|
||||
if (give > 0) caps[p] = current - give
|
||||
deficit -= give
|
||||
}
|
||||
for (const p of parts) take(p, FLOOR[p])
|
||||
take('name', SQUEEZED_NAME_FLOOR)
|
||||
return { stage, fold, caps, scopesGive: deficit > 0 }
|
||||
}
|
||||
@@ -80,7 +80,7 @@
|
||||
useOperatingWorkspace
|
||||
} from '$lib/components/operatingWorkspace.svelte'
|
||||
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
|
||||
import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte'
|
||||
import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte'
|
||||
import PathEditPopover from '$lib/components/PathEditPopover.svelte'
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
@@ -318,20 +318,15 @@
|
||||
|
||||
// Top-bar responsive collapse — container width, not viewport.
|
||||
let topbarWidth = $state(0)
|
||||
// In the page header the buttons share the row with the breadcrumb, so they collapse sooner:
|
||||
// the trail and the summary take ~430px of the same row, and the whole group — the menu, Diff,
|
||||
// Jobs, Export, AI and Deploy — is ~560px.
|
||||
const compactBelow = $derived(ownsPageHeader ? 1350 : 720)
|
||||
const compactTopbar = $derived.by(() => {
|
||||
const w = ownsPageHeader ? pageHeader.barWidth : topbarWidth
|
||||
return w > 0 && w < compactBelow
|
||||
})
|
||||
// A phone's bar keeps the trail, the file sidebar's toggle, Diff and Deploy. AI stands down —
|
||||
// the sidebar's own Ask AI is the way in at that width — while Jobs and the export are already
|
||||
// in the menu, Jobs from `compactTopbar` (true at any phone width) and the export always.
|
||||
const phoneTopbar = $derived(
|
||||
ownsPageHeader && pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR
|
||||
// In the page header the bar folds the buttons (`actionFolds` below), once its breadcrumb has
|
||||
// given all it can; anywhere else this editor's own top bar is theirs alone.
|
||||
const compactTopbar = $derived(
|
||||
ownsPageHeader ? pageHeader.actionFold >= 1 : topbarWidth > 0 && topbarWidth < 720
|
||||
)
|
||||
// The second fold keeps the trail, the file sidebar's toggle, Diff and Deploy. AI stands down —
|
||||
// the sidebar's own Ask AI is the way in at that width — while Jobs and the export are already
|
||||
// in the menu, Jobs from `compactTopbar` (true at the second fold too) and the export always.
|
||||
const phoneTopbar = $derived(ownsPageHeader && pageHeader.actionFold >= 2)
|
||||
|
||||
// Top-bar button size + bar height. Condensed (session preview) uses the
|
||||
// smallest well-supported unified size (`sm`) so the bar is thinner.
|
||||
@@ -914,8 +909,8 @@
|
||||
</a>
|
||||
— a public page that needs no login and can be embedded in an iframe. Host the JSON anywhere
|
||||
it can be fetched (S3, GitHub raw, your docs site) and link
|
||||
<span class="font-mono">/replay?src=<url></span> to have it load itself. Windmill
|
||||
keeps no copy.
|
||||
<span class="font-mono">/replay?src=<url></span> to have it load itself. Windmill keeps
|
||||
no copy.
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0">
|
||||
@@ -960,6 +955,7 @@
|
||||
pathTrigger: rawAppPathTrigger
|
||||
}}
|
||||
actions={rawAppHeaderActions}
|
||||
actionFolds={2}
|
||||
contexts={headerContexts}
|
||||
separator="none"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user