Implement responsive browser toolbar overflow with tool folding (#23265)

Toolbar buttons fold into a dropdown menu when space is constrained,
preserving minimum address bar width. ArtifactPublishButton gains
controlled popover state with optional virtual anchoring. Tour-pinned
tools stay visible while others fold in priority order.
This commit is contained in:
Jinjing
2026-09-26 14:23:20 -07:00
committed by GitHub
parent fe50f3ed04
commit b42de170b1
17 changed files with 1136 additions and 174 deletions
@@ -1,12 +1,28 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import type { ReactNode } from 'react'
import { createRef, type ReactNode } from 'react'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
type ArtifactPublishButtonMocks = {
connect: ReturnType<typeof vi.fn>
openSettingsPage: ReturnType<typeof vi.fn>
openSettingsTarget: ReturnType<typeof vi.fn>
getPublishedLink: ReturnType<typeof vi.fn>
copyLink: ReturnType<typeof vi.fn>
openLink: ReturnType<typeof vi.fn>
publish: ReturnType<typeof vi.fn>
openPopover: ((open: boolean) => void) | null
closePopover: ((event: Event) => void) | null
state: {
orcaProfileAuthStatus: Record<string, unknown>
settings: { artifactSharingEnabled: boolean }
}
}
const mocks = vi.hoisted<ArtifactPublishButtonMocks>(() => ({
connect: vi.fn(),
openSettingsPage: vi.fn(),
openSettingsTarget: vi.fn(),
@@ -14,9 +30,10 @@ const mocks = vi.hoisted(() => ({
copyLink: vi.fn(),
openLink: vi.fn(),
publish: vi.fn(),
openPopover: null as ((open: boolean) => void) | null,
openPopover: null,
closePopover: null,
state: {
orcaProfileAuthStatus: { configured: true, state: 'connected' } as Record<string, unknown>,
orcaProfileAuthStatus: { configured: true, state: 'connected' },
settings: { artifactSharingEnabled: true }
}
}))
@@ -42,7 +59,17 @@ vi.mock('@/components/ui/popover', () => ({
mocks.openPopover = onOpenChange ?? null
return <>{children}</>
},
PopoverContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
PopoverAnchor: ({ children }: { children?: ReactNode }) => <>{children}</>,
PopoverContent: ({
children,
onCloseAutoFocus
}: {
children: ReactNode
onCloseAutoFocus?: (event: Event) => void
}) => {
mocks.closePopover = onCloseAutoFocus ?? null
return <div>{children}</div>
},
PopoverTrigger: ({ children }: { children: ReactNode }) => (
<span onClick={() => mocks.openPopover?.(true)}>{children}</span>
)
@@ -81,6 +108,7 @@ describe('ArtifactPublishButton', () => {
mocks.getPublishedLink.mockResolvedValue(null)
mocks.copyLink.mockResolvedValue(true)
mocks.openPopover = null
mocks.closePopover = null
mocks.state.orcaProfileAuthStatus = { configured: true, state: 'connected' }
mocks.state.settings = { artifactSharingEnabled: true }
})
@@ -101,6 +129,31 @@ describe('ArtifactPublishButton', () => {
expect(screen.getByRole('button', { name: 'Update shared content' })).toBeInTheDocument()
})
it('supports a controlled virtual anchor without rendering a second trigger', async () => {
const anchorRef = createRef<HTMLButtonElement>()
render(
<>
<button ref={anchorRef}>Overflow</button>
<ArtifactPublishButton
sourceKey="/repo/report.md"
createRequest={vi.fn()}
anchorRef={anchorRef}
open
onOpenChange={vi.fn()}
/>
</>
)
expect(screen.queryByRole('button', { name: 'Share as artifact' })).toBeNull()
expect(await screen.findByRole('button', { name: 'Generate link' })).toBeInTheDocument()
const focus = vi.spyOn(anchorRef.current!, 'focus')
const closeEvent = new Event('close', { cancelable: true })
mocks.closePopover?.(closeEvent)
expect(closeEvent.defaultPrevented).toBe(true)
expect(focus).toHaveBeenCalledWith({ preventScroll: true })
})
it('offers sign-in and blocks confirmation while signed out', async () => {
const user = userEvent.setup()
mocks.state.orcaProfileAuthStatus = { configured: true, state: 'local' }
@@ -1,8 +1,8 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState, type RefObject } from 'react'
import { ArrowRight, Loader2, Share2 } from 'lucide-react'
import type { ArtifactWriteRequest } from '../../../../shared/artifacts'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
@@ -21,14 +21,26 @@ export function ArtifactPublishButton({
sourceKey,
createRequest,
className,
disabled
disabled,
anchorRef,
open: controlledOpen,
onOpenChange
}: {
sourceKey: string
createRequest: () => Promise<ArtifactWriteRequest>
className?: string
disabled?: boolean
}): React.JSX.Element {
const [open, setOpen] = useState(false)
} & (
| { anchorRef?: never; open?: never; onOpenChange?: never }
| {
anchorRef?: RefObject<HTMLButtonElement | null>
open: boolean
onOpenChange: (open: boolean) => void
}
)): React.JSX.Element {
const [ownOpen, setOwnOpen] = useState(false)
const open = controlledOpen ?? ownOpen
const setOpen = onOpenChange ?? setOwnOpen
const [publishing, setPublishing] = useState(false)
const [lookupRevision, setLookupRevision] = useState(0)
const [linkLookup, setLinkLookup] = useState<PublishedLinkLookup | null>(null)
@@ -111,25 +123,32 @@ export function ArtifactPublishButton({
)
return (
<Popover open={open} onOpenChange={(nextOpen) => !busy && setOpen(nextOpen)}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn('shrink-0', className)}
disabled={blocked}
aria-label={label}
>
{publishing ? <Loader2 className="animate-spin" /> : <Share2 />}
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{label}
</TooltipContent>
</Tooltip>
{anchorRef ? (
<PopoverAnchor
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: DOM refs are transiently null before mount, though Radix's virtualRef type omits that lifecycle state.
virtualRef={anchorRef as RefObject<HTMLButtonElement>}
/>
) : (
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn('shrink-0', className)}
disabled={blocked}
aria-label={label}
>
{publishing ? <Loader2 className="animate-spin" /> : <Share2 />}
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{label}
</TooltipContent>
</Tooltip>
)}
<PopoverContent
ref={popoverContentRef}
@@ -141,6 +160,13 @@ export function ArtifactPublishButton({
event.preventDefault()
popoverContentRef.current?.focus({ preventScroll: true })
}}
onCloseAutoFocus={(event) => {
if (!anchorRef) {
return
}
event.preventDefault()
anchorRef.current?.focus({ preventScroll: true })
}}
>
<div className="space-y-1 border-b border-border/60 px-4 py-3.5">
<h3 className="text-sm font-semibold">
@@ -4,7 +4,7 @@
* STA-3811: imports never touch the Google cookie family, so every import menu must disclose it
* at the moment of decision.
*/
import { act, type ReactNode } from 'react'
import { act, createRef, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import en from '@/i18n/locales/en.json'
@@ -67,6 +67,12 @@ describe('cookie-import Google disclosure footer', () => {
onImportFromFile={vi.fn()}
viewportPresetId={null}
onApplyViewportPreset={vi.fn()}
overflow={{
triggerRef: createRef<HTMLButtonElement>(),
tools: [],
deferUntilClose: vi.fn(),
onMenuCloseAutoFocus: vi.fn()
}}
/>
)
],
@@ -26,10 +26,12 @@ import { translate } from '@/i18n/i18n'
type BrowserImportHintButtonProps = {
profileId: string | null
compact?: boolean
}
export function BrowserImportHintButton({
profileId
profileId,
compact = false
}: BrowserImportHintButtonProps): React.JSX.Element | null {
const [open, setOpen] = useState(false)
const [importMenuOpen, setImportMenuOpen] = useState(false)
@@ -178,7 +180,12 @@ export function BrowserImportHintButton({
data-contextual-tour-target="browser-import-hint"
>
<Import className="size-3.5" />
{translate('auto.components.browser.pane.BrowserImportHintButton.b24fef25be', 'Import')}
{compact
? null
: translate(
'auto.components.browser.pane.BrowserImportHintButton.b24fef25be',
'Import'
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" side="bottom" sideOffset={6} className="w-80 p-3">
@@ -12,6 +12,7 @@ import {
import { BrowserToolbarMenuDropdown } from './browser-toolbar-menu-dropdown'
import { BrowserToolbarProfileDialogs } from './browser-toolbar-profile-dialogs'
import { translate } from '@/i18n/i18n'
import type { BrowserChromeOverflowMenuProps } from './browser-chrome-folded-tools'
type BrowserToolbarMenuProps = {
currentProfileId: string | null
@@ -20,6 +21,7 @@ type BrowserToolbarMenuProps = {
viewportPresetId: BrowserViewportPresetId | null
onDestroyWebview: () => void
isActive: boolean
overflow: BrowserChromeOverflowMenuProps
}
export function BrowserToolbarMenu({
@@ -28,7 +30,8 @@ export function BrowserToolbarMenu({
browserPageId,
viewportPresetId,
onDestroyWebview,
isActive
isActive,
overflow
}: BrowserToolbarMenuProps): React.JSX.Element {
const browserSessionProfiles = useAppStore((s) => s.browserSessionProfiles)
const detectedBrowsers = useAppStore((s) => s.detectedBrowsers)
@@ -241,6 +244,7 @@ export function BrowserToolbarMenu({
onImportFromFile={() => void handleImportFromFile()}
viewportPresetId={viewportPresetId}
onApplyViewportPreset={applyViewportPreset}
overflow={overflow}
/>
<BrowserToolbarProfileDialogs
@@ -0,0 +1,109 @@
import { Crosshair, MessageSquarePlus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import type { GrabIntent } from '../describe-page/browser-page-types'
/** The in-guest element picker, driving both Grab (copy) and Annotate (comment). */
export type BrowserChromeElementTools = {
/** The intent the picker is armed for right now, or null when it is idle. */
activeIntent: GrabIntent | null
onStartIntent: (intent: GrabIntent) => void
disabled: boolean
grabShortcutLabel: string
annotationCount: number
}
export function BrowserChromeElementToolButtons({
tools,
showGrab,
showAnnotate,
showTourAnchors
}: {
tools: BrowserChromeElementTools
showGrab: boolean
showAnnotate: boolean
showTourAnchors: boolean
}): React.JSX.Element {
return (
<>
{showGrab ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Button
size="icon"
variant={tools.activeIntent === 'copy' ? 'default' : 'ghost'}
className={cn(
'h-8 w-8',
tools.activeIntent === 'copy' &&
'bg-foreground/80 text-background hover:bg-foreground/90'
)}
onClick={() => tools.onStartIntent('copy')}
disabled={tools.disabled}
aria-label={translate(
'auto.components.browser.pane.BrowserPane.fdfc7fe0ef',
'Grab page element'
)}
{...(showTourAnchors
? { 'data-contextual-tour-target': 'browser-grab-control' }
: {})}
>
<Crosshair className="size-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{translate(
'auto.components.browser.pane.BrowserPane.acbe79fd01',
'Grab page element ({{value0}})',
{ value0: tools.grabShortcutLabel }
)}
</TooltipContent>
</Tooltip>
) : null}
{showAnnotate ? (
<Tooltip>
<TooltipTrigger asChild>
{/* Why: disabled buttons drop hover events, so the tooltip needs an enabled wrapper. */}
<span className="inline-flex">
<Button
size="icon"
variant={tools.activeIntent === 'annotate' ? 'default' : 'ghost'}
className={cn(
'relative h-8 w-8',
tools.activeIntent === 'annotate' &&
'bg-foreground/80 text-background hover:bg-foreground/90'
)}
onClick={() => tools.onStartIntent('annotate')}
disabled={tools.disabled}
aria-label={translate(
'auto.components.browser.pane.BrowserPane.fc9be38f6f',
'Annotate page element'
)}
{...(showTourAnchors
? { 'data-contextual-tour-target': 'browser-annotation-control' }
: {})}
>
<MessageSquarePlus className="size-4" />
{tools.annotationCount > 0 ? (
<span className="absolute -top-1 -right-1 flex min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] leading-4 text-primary-foreground">
{tools.annotationCount}
</span>
) : null}
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{translate(
'auto.components.browser.pane.BrowserPane.fc9be38f6f',
'Annotate page element'
)}
</TooltipContent>
</Tooltip>
) : null}
</>
)
}
@@ -0,0 +1,107 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Share2 } from 'lucide-react'
vi.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenuItem: ({
children,
onSelect,
role,
'aria-checked': ariaChecked
}: {
children: React.ReactNode
onSelect: () => void
role?: string
'aria-checked'?: boolean
}) => (
<button role={role} aria-checked={ariaChecked} onClick={onSelect}>
{children}
</button>
),
DropdownMenuSeparator: () => <hr />,
DropdownMenuShortcut: ({ children }: { children: React.ReactNode }) => <span>{children}</span>
}))
import { BrowserChromeFoldedMenuItems } from './browser-chrome-folded-tools'
afterEach(cleanup)
describe('BrowserChromeFoldedMenuItems', () => {
it('runs ordinary actions directly', () => {
const onSelect = vi.fn()
render(
<BrowserChromeFoldedMenuItems
tools={[{ stage: 'share', label: 'Share', icon: Share2, onSelect }]}
deferUntilClose={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'Share' }))
expect(onSelect).toHaveBeenCalledOnce()
})
it('defers popover actions until the menu closes', () => {
const onSelect = vi.fn()
const deferUntilClose = vi.fn()
render(
<BrowserChromeFoldedMenuItems
tools={[
{
stage: 'share',
label: 'Share',
icon: Share2,
onSelect,
deferUntilMenuClose: true
}
]}
deferUntilClose={deferUntilClose}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'Share' }))
expect(onSelect).not.toHaveBeenCalled()
expect(deferUntilClose).toHaveBeenCalledWith(onSelect)
})
it('preserves active state for folded toggle tools', () => {
render(
<BrowserChromeFoldedMenuItems
tools={[
{
stage: 'draw',
label: 'Draw',
icon: Share2,
onSelect: vi.fn(),
active: true
}
]}
deferUntilClose={vi.fn()}
/>
)
expect(
screen.getByRole('menuitemcheckbox', { name: 'Draw' }).getAttribute('aria-checked')
).toBe('true')
})
it('gives toggle tools their own section, split from the actions by a divider', () => {
const { container } = render(
<BrowserChromeFoldedMenuItems
tools={[
{ stage: 'draw', label: 'Draw', icon: Share2, onSelect: vi.fn(), active: false },
{ stage: 'share', label: 'Share', icon: Share2, onSelect: vi.fn() }
]}
deferUntilClose={vi.fn()}
/>
)
// Rendered order: Draw, divider, Share, closing divider.
expect(Array.from(container.querySelectorAll('button, hr')).map((el) => el.tagName)).toEqual([
'BUTTON',
'HR',
'BUTTON',
'HR'
])
})
})
@@ -0,0 +1,106 @@
import { Check } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import {
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut
} from '@/components/ui/dropdown-menu'
import type { BrowserChromeFoldStage } from './use-browser-chrome-tool-fold'
export type BrowserChromeFoldedTool = {
stage: BrowserChromeFoldStage
label: string
icon: LucideIcon
onSelect: () => void
disabled?: boolean
shortcut?: string
count?: number
/** Present for toggle tools, including false, so the menu preserves their pressed state. */
active?: boolean
/**
* Run only after the menu has closed and skip its focus return. A tool that opens its own
* popover needs this: focus landing back on the ⋯ trigger would dismiss that popover at once.
*/
deferUntilMenuClose?: boolean
}
export type BrowserChromeOverflowMenuProps = {
triggerRef: React.RefObject<HTMLButtonElement | null>
tools: readonly BrowserChromeFoldedTool[]
deferUntilClose: (action: () => void) => void
onMenuCloseAutoFocus: (event: Event) => void
}
function FoldedToolContent({ tool }: { tool: BrowserChromeFoldedTool }): React.JSX.Element {
const showActiveCheck = tool.active === true
const showTrailing = showActiveCheck || Boolean(tool.count) || Boolean(tool.shortcut)
return (
<>
<tool.icon className="size-3.5" />
{tool.label}
{showTrailing ? (
// Why: a leading check column would push these icon rows out of line with the plain rows.
<span className="ml-auto flex items-center gap-2">
{showActiveCheck ? <Check className="size-3.5 text-foreground" /> : null}
{tool.count ? (
<span className="flex min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] leading-4 text-primary-foreground">
{tool.count}
</span>
) : tool.shortcut ? (
<DropdownMenuShortcut>{tool.shortcut}</DropdownMenuShortcut>
) : null}
</span>
) : null}
</>
)
}
function FoldedToolRow({
tool,
deferUntilClose
}: {
tool: BrowserChromeFoldedTool
deferUntilClose: (action: () => void) => void
}): React.JSX.Element {
const onSelect = (): void =>
tool.deferUntilMenuClose ? deferUntilClose(tool.onSelect) : tool.onSelect()
return (
<DropdownMenuItem
disabled={tool.disabled}
onSelect={onSelect}
// Why: keeps toggle semantics without the checkbox item's reserved leading column,
// which would misalign this row's icon against the plain rows in the same menu.
{...(tool.active === undefined
? {}
: { role: 'menuitemcheckbox', 'aria-checked': tool.active })}
>
<FoldedToolContent tool={tool} />
</DropdownMenuItem>
)
}
/** The toolbar tools that no longer fit, rendered at the top of a surface's ⋯ menu. */
export function BrowserChromeFoldedMenuItems({
tools,
deferUntilClose
}: Pick<BrowserChromeOverflowMenuProps, 'tools' | 'deferUntilClose'>): React.JSX.Element | null {
if (tools.length === 0) {
return null
}
// Why: the element/markup toggles fold first and carry on/off state, so they read as their own
// section ahead of the one-shot actions below rather than as one flat tool list.
const toggleTools = tools.filter((tool) => tool.active !== undefined)
const actionTools = tools.filter((tool) => tool.active === undefined)
return (
<>
{toggleTools.map((tool) => (
<FoldedToolRow key={tool.stage} tool={tool} deferUntilClose={deferUntilClose} />
))}
{toggleTools.length > 0 && actionTools.length > 0 ? <DropdownMenuSeparator /> : null}
{actionTools.map((tool) => (
<FoldedToolRow key={tool.stage} tool={tool} deferUntilClose={deferUntilClose} />
))}
<DropdownMenuSeparator />
</>
)
}
@@ -0,0 +1,158 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { useState } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { BrowserChromeFoldStage } from './use-browser-chrome-tool-fold'
import type { BrowserChromeFoldedTool } from './browser-chrome-folded-tools'
const mocks = vi.hoisted(() => {
const stages: readonly BrowserChromeFoldStage[] = []
return { folded: new Set<BrowserChromeFoldStage>(), stages }
})
vi.mock('./use-browser-chrome-tool-fold', () => ({
BROWSER_CHROME_FOLD_ORDER: [
'import-label',
'external',
'devtools',
'share',
'import',
'draw',
'grab',
'annotate'
],
useBrowserChromeToolFold: (_rowRef: unknown, stages: readonly BrowserChromeFoldStage[]) => {
mocks.stages = stages
return mocks.folded
}
}))
vi.mock('./browser-navigation-control-row', () => ({
BrowserNavigationControlRow: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
)
}))
vi.mock('../annotate/MarkupDrawButton', () => ({
MarkupDrawButton: () => <button>Draw</button>
}))
vi.mock('./browser-chrome-element-tool-buttons', () => ({
BrowserChromeElementToolButtons: () => null
}))
import { BrowserChromeToolbar } from './browser-chrome-toolbar'
const controls = {
canGoBack: false,
canGoForward: false,
loading: false,
goBack: vi.fn(),
goForward: vi.fn(),
reload: vi.fn(),
navigate: vi.fn()
}
const emptyOverflowMenu = (): null => null
beforeEach(() => {
mocks.folded = new Set()
mocks.stages = []
})
afterEach(cleanup)
describe('BrowserChromeToolbar', () => {
it('does not duplicate an action already provided by the surface menu', () => {
mocks.folded = new Set(['devtools', 'external'])
let foldedTools: readonly BrowserChromeFoldedTool[] = []
render(
<BrowserChromeToolbar
controls={controls}
addressSlot={null}
elementTools={null}
markup={{ active: false, disabled: false, onToggle: vi.fn(), canShowDiscoveryHint: false }}
viewSource={{
label: 'Open source file',
onSelect: vi.fn(),
alreadyInOverflowMenu: true
}}
openExternal={{ label: 'Open with default app', onSelect: vi.fn() }}
overflowMenu={(overflow) => {
foldedTools = overflow.tools
return null
}}
/>
)
expect(foldedTools.map((tool) => tool.stage)).toEqual(['external'])
})
it('compacts the import hint before removing it', () => {
mocks.folded = new Set(['import-label'])
const importControl = vi.fn(() => null)
render(
<BrowserChromeToolbar
controls={controls}
addressSlot={null}
importControl={importControl}
elementTools={null}
markup={{ active: false, disabled: false, onToggle: vi.fn(), canShowDiscoveryHint: false }}
viewSource={null}
openExternal={null}
overflowMenu={emptyOverflowMenu}
/>
)
expect(importControl).toHaveBeenCalledWith(true)
})
it('keeps the active tour control out of the fold sequence', () => {
render(
<BrowserChromeToolbar
controls={controls}
addressSlot={null}
pinnedStage="grab"
elementTools={{
activeIntent: null,
onStartIntent: vi.fn(),
disabled: false,
grabShortcutLabel: 'Cmd+Shift+C',
annotationCount: 0
}}
markup={{ active: false, disabled: false, onToggle: vi.fn(), canShowDiscoveryHint: false }}
viewSource={null}
openExternal={null}
overflowMenu={emptyOverflowMenu}
/>
)
expect(mocks.stages).not.toContain('grab')
expect(mocks.stages).toContain('annotate')
})
it('keeps the same share control mounted when it moves into overflow', () => {
function StatefulShare(): React.JSX.Element {
const [clicks, setClicks] = useState(0)
return <button onClick={() => setClicks((count) => count + 1)}>Share state {clicks}</button>
}
const props = {
controls,
addressSlot: null,
elementTools: null,
markup: { active: false, disabled: false, onToggle: vi.fn(), canShowDiscoveryHint: false },
shareControl: () => <StatefulShare />,
viewSource: null,
openExternal: null,
overflowMenu: emptyOverflowMenu
}
const view = render(<BrowserChromeToolbar {...props} />)
fireEvent.click(screen.getByRole('button', { name: 'Share state 0' }))
mocks.folded = new Set(['share'])
view.rerender(<BrowserChromeToolbar {...props} />)
expect(screen.getByRole('button', { name: 'Share state 1' })).not.toBeNull()
})
})
@@ -1,24 +1,35 @@
import { cn } from '@/lib/utils'
import { Crosshair, ExternalLink, MessageSquarePlus, SquareCode } from 'lucide-react'
import { useRef, useState } from 'react'
import {
Crosshair,
ExternalLink,
MessageSquarePlus,
PenTool,
Share2,
SquareCode
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
import {
BrowserNavigationControlRow,
type BrowserNavigationControls
} from './browser-navigation-control-row'
import { MarkupDrawButton } from '../annotate/MarkupDrawButton'
import type { GrabIntent } from '../describe-page/browser-page-types'
import type {
BrowserChromeFoldedTool,
BrowserChromeOverflowMenuProps
} from './browser-chrome-folded-tools'
import {
BROWSER_CHROME_FOLD_ORDER,
useBrowserChromeToolFold,
type BrowserChromeFoldStage
} from './use-browser-chrome-tool-fold'
import {
BrowserChromeElementToolButtons,
type BrowserChromeElementTools
} from './browser-chrome-element-tool-buttons'
/** The in-guest element picker, driving both Grab (copy) and Annotate (comment). */
export type BrowserChromeElementTools = {
/** The intent the picker is armed for right now, or null when it is idle. */
activeIntent: GrabIntent | null
onStartIntent: (intent: GrabIntent) => void
disabled: boolean
grabShortcutLabel: string
annotationCount: number
}
export type { BrowserChromeElementTools } from './browser-chrome-element-tool-buttons'
export type BrowserChromeMarkupTool = {
active: boolean
@@ -33,10 +44,40 @@ export type BrowserChromeMarkupTool = {
canShowDiscoveryHint: boolean
}
export type BrowserChromeShareControl = {
open: boolean
onOpenChange: (open: boolean) => void
anchorRef?: React.RefObject<HTMLButtonElement | null>
}
export type BrowserChromeToolAction = {
onSelect: () => void
label: string
disabled?: boolean
/** True when the surface's overflow menu already exposes this action. */
alreadyInOverflowMenu?: boolean
}
function BrowserChromeActionButton({
action,
icon: Icon
}: {
action: BrowserChromeToolAction
icon: LucideIcon
}): React.JSX.Element {
return (
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={action.onSelect}
title={action.label}
aria-label={action.label}
disabled={action.disabled}
>
<Icon className="size-4" />
</Button>
)
}
/**
@@ -59,148 +100,170 @@ export function BrowserChromeToolbar({
viewSource,
openExternal,
overflowMenu,
showTourAnchors = false
showTourAnchors = false,
pinnedStage
}: {
controls: BrowserNavigationControls
addressSlot: React.ReactNode
reloadControl?: React.ReactNode
reloadLabel?: string
/** Cookie import — a browsing session concept; null where there is no session to import into. */
importControl?: React.ReactNode
importControl?: ((compact: boolean) => React.ReactNode) | null
elementTools: BrowserChromeElementTools | null
markup: BrowserChromeMarkupTool
shareControl?: React.ReactNode
shareControl?: (control: BrowserChromeShareControl) => React.ReactNode
viewSource: BrowserChromeToolAction | null
openExternal: BrowserChromeToolAction | null
overflowMenu?: React.ReactNode
overflowMenu: (props: BrowserChromeOverflowMenuProps) => React.ReactNode
/** Only the browsing pane anchors the contextual tour; a second anchor would steal its steps. */
showTourAnchors?: boolean
/** Keeps the active contextual-tour control measurable while the remaining tools still fold. */
pinnedStage?: BrowserChromeFoldStage
}): React.JSX.Element {
const rowRef = useRef<HTMLDivElement>(null)
const overflowTriggerRef = useRef<HTMLButtonElement>(null)
const present: Record<BrowserChromeFoldStage, boolean> = {
'import-label': importControl != null,
import: importControl != null,
external: openExternal !== null,
devtools: viewSource !== null,
share: shareControl != null,
draw: true,
grab: elementTools !== null,
annotate: elementTools !== null
}
const stages = BROWSER_CHROME_FOLD_ORDER.filter(
(stage) => present[stage] && stage !== pinnedStage
)
const folded = useBrowserChromeToolFold(rowRef, stages)
const [sharePopoverOpen, setSharePopoverOpen] = useState(false)
const afterMenuCloseRef = useRef<(() => void) | null>(null)
const foldedTools: BrowserChromeFoldedTool[] = []
if (elementTools && folded.has('grab')) {
foldedTools.push({
stage: 'grab',
label: translate('auto.components.browser.pane.BrowserPane.fdfc7fe0ef', 'Grab page element'),
icon: Crosshair,
onSelect: () => elementTools.onStartIntent('copy'),
disabled: elementTools.disabled,
shortcut: elementTools.grabShortcutLabel,
active: elementTools.activeIntent === 'copy'
})
}
if (elementTools && folded.has('annotate')) {
foldedTools.push({
stage: 'annotate',
label: translate(
'auto.components.browser.pane.BrowserPane.fc9be38f6f',
'Annotate page element'
),
icon: MessageSquarePlus,
onSelect: () => elementTools.onStartIntent('annotate'),
disabled: elementTools.disabled,
count: elementTools.annotationCount,
active: elementTools.activeIntent === 'annotate'
})
}
if (folded.has('draw')) {
foldedTools.push({
stage: 'draw',
label: translate('auto.components.browser-pane.markup.drawButton', 'Draw on screenshot'),
icon: PenTool,
onSelect: markup.onToggle,
disabled: markup.disabled,
active: markup.active
})
}
if (shareControl && folded.has('share')) {
foldedTools.push({
stage: 'share',
label: translate(
'auto.components.artifacts.ArtifactPublishButton.a4a49da6af',
'Share as artifact'
),
icon: Share2,
onSelect: () => setSharePopoverOpen(true),
deferUntilMenuClose: true
})
}
if (viewSource && folded.has('devtools') && !viewSource.alreadyInOverflowMenu) {
foldedTools.push({ stage: 'devtools', icon: SquareCode, ...viewSource })
}
if (openExternal && folded.has('external') && !openExternal.alreadyInOverflowMenu) {
foldedTools.push({ stage: 'external', icon: ExternalLink, ...openExternal })
}
const runAfterMenuClose = (action: () => void): void => {
afterMenuCloseRef.current = action
}
const onMenuCloseAutoFocus = (event: Event): void => {
const action = afterMenuCloseRef.current
if (!action) {
return
}
afterMenuCloseRef.current = null
event.preventDefault()
action()
}
const showFoldedAnnotationDot = folded.has('annotate') && (elementTools?.annotationCount ?? 0) > 0
return (
<BrowserNavigationControlRow
rowRef={rowRef}
controls={controls}
addressSlot={addressSlot}
reloadControl={reloadControl}
reloadLabel={reloadLabel}
showTourAnchors={showTourAnchors}
>
{importControl}
{folded.has('import') ? null : importControl?.(folded.has('import-label'))}
{elementTools ? (
<>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Button
size="icon"
variant={elementTools.activeIntent === 'copy' ? 'default' : 'ghost'}
className={cn(
'h-8 w-8',
elementTools.activeIntent === 'copy' &&
'bg-foreground/80 text-background hover:bg-foreground/90'
)}
onClick={() => elementTools.onStartIntent('copy')}
disabled={elementTools.disabled}
aria-label={translate(
'auto.components.browser.pane.BrowserPane.fdfc7fe0ef',
'Grab page element'
)}
{...(showTourAnchors
? { 'data-contextual-tour-target': 'browser-grab-control' }
: {})}
>
<Crosshair className="size-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{translate(
'auto.components.browser.pane.BrowserPane.acbe79fd01',
'Grab page element ({{value0}})',
{ value0: elementTools.grabShortcutLabel }
)}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
{/* Why: disabled <button> drops hover events, so wrap in a span so the tooltip trigger still fires. */}
<span className="inline-flex">
<Button
size="icon"
variant={elementTools.activeIntent === 'annotate' ? 'default' : 'ghost'}
className={cn(
'relative h-8 w-8',
elementTools.activeIntent === 'annotate' &&
'bg-foreground/80 text-background hover:bg-foreground/90'
)}
onClick={() => elementTools.onStartIntent('annotate')}
disabled={elementTools.disabled}
aria-label={translate(
'auto.components.browser.pane.BrowserPane.fc9be38f6f',
'Annotate page element'
)}
{...(showTourAnchors
? { 'data-contextual-tour-target': 'browser-annotation-control' }
: {})}
>
<MessageSquarePlus className="size-4" />
{elementTools.annotationCount > 0 ? (
<span className="absolute -top-1 -right-1 flex min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] leading-4 text-primary-foreground">
{elementTools.annotationCount}
</span>
) : null}
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{translate(
'auto.components.browser.pane.BrowserPane.fc9be38f6f',
'Annotate page element'
)}
</TooltipContent>
</Tooltip>
</>
<BrowserChromeElementToolButtons
tools={elementTools}
showGrab={!folded.has('grab')}
showAnnotate={!folded.has('annotate')}
showTourAnchors={showTourAnchors}
/>
) : null}
<MarkupDrawButton
onClick={markup.onToggle}
disabled={markup.disabled}
active={markup.active}
surfaceActive={markup.canShowDiscoveryHint}
/>
{folded.has('draw') ? null : (
<MarkupDrawButton
onClick={markup.onToggle}
disabled={markup.disabled}
active={markup.active}
surfaceActive={markup.canShowDiscoveryHint}
/>
)}
{shareControl}
{shareControl?.({
open: sharePopoverOpen,
onOpenChange: setSharePopoverOpen,
anchorRef: folded.has('share') ? overflowTriggerRef : undefined
})}
{viewSource ? (
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={viewSource.onSelect}
title={viewSource.label}
aria-label={viewSource.label}
disabled={viewSource.disabled}
>
<SquareCode className="size-4" />
</Button>
{viewSource && !folded.has('devtools') ? (
<BrowserChromeActionButton action={viewSource} icon={SquareCode} />
) : null}
{openExternal ? (
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={openExternal.onSelect}
title={openExternal.label}
aria-label={openExternal.label}
disabled={openExternal.disabled}
>
<ExternalLink className="size-4" />
</Button>
{openExternal && !folded.has('external') ? (
<BrowserChromeActionButton action={openExternal} icon={ExternalLink} />
) : null}
{overflowMenu}
<span className="relative inline-flex">
{overflowMenu({
triggerRef: overflowTriggerRef,
tools: foldedTools,
deferUntilClose: runAfterMenuClose,
onMenuCloseAutoFocus
})}
{/* Why: keeps pending annotations visible once the annotate button has folded into ⋯. */}
{showFoldedAnnotationDot ? (
<span className="pointer-events-none absolute top-1 right-1 size-1.5 rounded-full bg-primary ring-2 ring-background" />
) : null}
</span>
</BrowserNavigationControlRow>
)
}
@@ -36,6 +36,7 @@ export function BrowserNavigationControlRow({
reloadControl,
reloadLabel,
showTourAnchors = true,
rowRef,
children
}: {
controls: BrowserNavigationControls
@@ -46,10 +47,12 @@ export function BrowserNavigationControlRow({
reloadLabel?: string
/** Off for surfaces the browsing tour does not cover — a second anchor would steal its steps. */
showTourAnchors?: boolean
rowRef?: React.Ref<HTMLDivElement>
children?: React.ReactNode
}): React.JSX.Element {
return (
<div
ref={rowRef}
className="relative z-10 flex shrink-0 items-center gap-2 border-b border-border/70 bg-background/95 px-3 py-1.5"
{...(showTourAnchors ? { 'data-contextual-tour-target': 'browser-toolbar' } : {})}
>
@@ -1,6 +1,7 @@
import type { Dispatch, RefObject, SetStateAction } from 'react'
import { ArtifactPublishButton } from '@/components/artifacts/ArtifactPublishButton'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import type { BrowserReloadTrigger } from '../navigate/browser-reload-action'
import BrowserAddressBar from './BrowserAddressBar'
import { BrowserChromeToolbar } from './browser-chrome-toolbar'
@@ -102,9 +103,20 @@ export function BrowserPageToolbar({
currentBrowserUrl: string
externalUrl: string | null
}): React.JSX.Element {
const browserTourStep = useAppStore((state) =>
state.activeContextualTourId === 'browser' ? state.activeContextualTourStepIndex : null
)
const pinnedStage =
browserTourStep === 0
? ('grab' as const)
: browserTourStep === 1
? ('annotate' as const)
: undefined
return (
<BrowserChromeToolbar
showTourAnchors
pinnedStage={pinnedStage}
controls={{
canGoBack: canGoBack || Boolean(convertedFrom),
canGoForward: canGoForward || Boolean(convertedTo),
@@ -161,7 +173,9 @@ export function BrowserPageToolbar({
onHardReload={() => runReloadTrigger('hard-reload')}
/>
}
importControl={<BrowserImportHintButton profileId={sessionProfileId} />}
importControl={(compact) => (
<BrowserImportHintButton profileId={sessionProfileId} compact={compact} />
)}
elementTools={{
activeIntent: grab.state !== 'idle' ? grabIntent : null,
onStartIntent: startGrabIntent,
@@ -176,13 +190,16 @@ export function BrowserPageToolbar({
canShowDiscoveryHint: isActive
}}
shareControl={
shareableArtifactFile ? (
<ArtifactPublishButton
sourceKey={shareableArtifactFile.filePath}
className="h-7 w-7"
createRequest={() => readBrowserHtmlArtifactRequest(currentBrowserUrl)}
/>
) : null
shareableArtifactFile
? (control) => {
const props = {
sourceKey: shareableArtifactFile.filePath,
className: 'h-7 w-7',
createRequest: () => readBrowserHtmlArtifactRequest(currentBrowserUrl)
}
return <ArtifactPublishButton {...props} {...control} />
}
: undefined
}
viewSource={{
onSelect: () => void window.api.browser.openDevTools({ browserPageId }),
@@ -204,7 +221,7 @@ export function BrowserPageToolbar({
),
disabled: !externalUrl
}}
overflowMenu={
overflowMenu={(overflow) => (
<BrowserToolbarMenu
currentProfileId={sessionProfileId}
workspaceId={workspaceId}
@@ -212,8 +229,9 @@ export function BrowserPageToolbar({
viewportPresetId={viewportPresetId}
onDestroyWebview={() => destroyPersistentWebview(browserPageId)}
isActive={isActive}
overflow={overflow}
/>
}
)}
/>
)
}
@@ -30,6 +30,10 @@ type DetectedBrowserEntry = {
}
import { BROWSER_VIEWPORT_PRESETS } from '../../../../../shared/browser-viewport-presets'
import { translate } from '@/i18n/i18n'
import {
BrowserChromeFoldedMenuItems,
type BrowserChromeOverflowMenuProps
} from './browser-chrome-folded-tools'
type BrowserToolbarMenuDropdownProps = {
menuOpen: boolean
@@ -45,6 +49,7 @@ type BrowserToolbarMenuDropdownProps = {
onImportFromFile: () => void
viewportPresetId: BrowserViewportPresetId | null
onApplyViewportPreset: (nextId: BrowserViewportPresetId | null) => void
overflow: BrowserChromeOverflowMenuProps
}
export function BrowserToolbarMenuDropdown({
@@ -60,12 +65,14 @@ export function BrowserToolbarMenuDropdown({
onImportFromBrowser,
onImportFromFile,
viewportPresetId,
onApplyViewportPreset
onApplyViewportPreset,
overflow
}: BrowserToolbarMenuDropdownProps): React.JSX.Element {
return (
<DropdownMenu modal={false} open={menuOpen} onOpenChange={onMenuOpenChange}>
<DropdownMenuTrigger asChild>
<Button
ref={overflow.triggerRef}
size="icon"
variant="ghost"
className="h-8 w-8"
@@ -77,7 +84,15 @@ export function BrowserToolbarMenuDropdown({
<Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuContent
align="end"
className="w-56"
onCloseAutoFocus={overflow.onMenuCloseAutoFocus}
>
<BrowserChromeFoldedMenuItems
tools={overflow.tools}
deferUntilClose={overflow.deferUntilClose}
/>
{allProfiles.map((profile) => {
const isSelectedProfile = profile.id === effectiveProfileId
return (
@@ -0,0 +1,159 @@
// @vitest-environment happy-dom
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useRef } from 'react'
import { BROWSER_CHROME_ADDRESS_SLOT_ATTRIBUTE } from './browser-chrome-address-slot'
import {
BROWSER_CHROME_ADDRESS_MIN_WIDTH_PX,
BROWSER_CHROME_FOLD_ORDER,
useBrowserChromeToolFold
} from './use-browser-chrome-tool-fold'
const FIXED_WIDTH = 100
const TOOL_WIDTH = 30
// A flex row where the address takes whatever the fixed controls and visible tools leave over.
const layout = { rowWidth: 600, visibleTools: BROWSER_CHROME_FOLD_ORDER.length }
let resizeCallbacks: (() => void)[] = []
function Host({
onFolded,
stages = BROWSER_CHROME_FOLD_ORDER
}: {
onFolded: (count: number) => void
stages?: readonly (typeof BROWSER_CHROME_FOLD_ORDER)[number][]
}): React.JSX.Element {
const rowRef = useRef<HTMLDivElement>(null)
const folded = useBrowserChromeToolFold(rowRef, stages)
layout.visibleTools = stages.length - folded.size
onFolded(folded.size)
return (
<div ref={rowRef} data-testid="row">
<div {...{ [BROWSER_CHROME_ADDRESS_SLOT_ATTRIBUTE]: 'true' }} />
</div>
)
}
function contentWidth(): number {
return FIXED_WIDTH + TOOL_WIDTH * layout.visibleTools
}
beforeEach(() => {
resizeCallbacks = []
vi.stubGlobal(
'ResizeObserver',
class {
constructor(callback: () => void) {
resizeCallbacks.push(callback)
}
observe(): void {}
disconnect(): void {}
}
)
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => layout.rowWidth)
vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(() =>
Math.max(layout.rowWidth, contentWidth())
)
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() =>
DOMRect.fromRect({ width: Math.max(0, layout.rowWidth - contentWidth()) })
)
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
function resizeTo(width: number): void {
layout.rowWidth = width
act(() => resizeCallbacks.forEach((callback) => callback()))
}
function expectedFolded(width: number): number {
const room = width - FIXED_WIDTH - BROWSER_CHROME_ADDRESS_MIN_WIDTH_PX
const fits = Math.max(0, Math.floor(room / TOOL_WIDTH))
return Math.max(0, BROWSER_CHROME_FOLD_ORDER.length - fits)
}
describe('useBrowserChromeToolFold', () => {
it('keeps every tool inline when the address has its minimum width', () => {
let folded = -1
layout.rowWidth = 600
render(<Host onFolded={(count) => (folded = count)} />)
expect(folded).toBe(0)
})
it('folds just enough tools to give the address its minimum width', () => {
let folded = -1
layout.rowWidth = 300
render(<Host onFolded={(count) => (folded = count)} />)
expect(folded).toBe(expectedFolded(300))
expect(folded).toBeGreaterThan(0)
})
it('unfolds as the row widens and refolds as it narrows', () => {
let folded = -1
layout.rowWidth = 600
render(<Host onFolded={(count) => (folded = count)} />)
resizeTo(260)
expect(folded).toBe(expectedFolded(260))
resizeTo(400)
expect(folded).toBe(expectedFolded(400))
resizeTo(700)
expect(folded).toBe(0)
})
it('folds everything but lets the address shrink once no tool is left', () => {
let folded = -1
layout.rowWidth = 150
render(<Host onFolded={(count) => (folded = count)} />)
expect(folded).toBe(BROWSER_CHROME_FOLD_ORDER.length)
})
it('leaves a hidden (zero-width) row alone', () => {
let folded = -1
layout.rowWidth = 0
render(<Host onFolded={(count) => (folded = count)} />)
expect(folded).toBe(0)
})
it('measures when a hidden row becomes visible', () => {
let folded = -1
layout.rowWidth = 0
render(<Host onFolded={(count) => (folded = count)} />)
resizeTo(300)
expect(folded).toBe(expectedFolded(300))
})
it('discards measurements when the available tools change', () => {
let folded = -1
layout.rowWidth = 300
const view = render(<Host onFolded={(count) => (folded = count)} />)
expect(folded).toBeGreaterThan(0)
view.rerender(<Host onFolded={(count) => (folded = count)} stages={['grab', 'annotate']} />)
expect(folded).toBe(0)
resizeTo(150)
expect(folded).toBe(2)
})
it('does not restore an obsolete fold level when a tool set returns', () => {
let folded = -1
layout.rowWidth = 300
const view = render(<Host onFolded={(count) => (folded = count)} />)
expect(folded).toBeGreaterThan(0)
layout.rowWidth = 600
view.rerender(<Host onFolded={(count) => (folded = count)} stages={['grab']} />)
expect(folded).toBe(0)
view.rerender(<Host onFolded={(count) => (folded = count)} />)
expect(folded).toBe(0)
})
})
@@ -0,0 +1,115 @@
import { useLayoutEffect, useRef, useState, type RefObject } from 'react'
import { BROWSER_CHROME_ADDRESS_SLOT_ATTRIBUTE } from './browser-chrome-address-slot'
/**
* One step of toolbar compaction. Steps apply in this order as the row narrows, so the least-used
* tools leave first and the page tools the chrome exists for (grab, annotate) leave last.
*/
export type BrowserChromeFoldStage =
| 'import-label'
| 'external'
| 'devtools'
| 'share'
| 'import'
| 'draw'
| 'grab'
| 'annotate'
export const BROWSER_CHROME_FOLD_ORDER: readonly BrowserChromeFoldStage[] = [
'import-label',
'external',
'devtools',
'share',
'import',
'draw',
'grab',
'annotate'
]
/** Below this the URL stops being readable, so tools fold before the address shrinks further. */
export const BROWSER_CHROME_ADDRESS_MIN_WIDTH_PX = 120
type RowMeasure = { address: number; overflow: number }
function measureRow(row: HTMLElement | null): RowMeasure | null {
const slot = row?.querySelector(`[${BROWSER_CHROME_ADDRESS_SLOT_ATTRIBUTE}]`)
// Why: a hidden pane measures zero; folding everything there would flash on reveal.
if (!row || !slot || row.clientWidth === 0) {
return null
}
return {
address: slot.getBoundingClientRect().width,
overflow: Math.max(0, row.scrollWidth - row.clientWidth)
}
}
/**
* Folds `stages` one at a time until the address slot keeps its minimum width and the row stops
* overflowing, and unfolds as room returns.
*
* Why record the width each fold freed instead of re-measuring from zero on every resize: resetting
* would re-render the toolbar once per stage per resize frame. A fold is undone only when the
* address has that much slack, so an unfold can never immediately re-trigger its own fold.
*/
export function useBrowserChromeToolFold(
rowRef: RefObject<HTMLElement | null>,
stages: readonly BrowserChromeFoldStage[]
): ReadonlySet<BrowserChromeFoldStage> {
const stagesKey = stages.join(',')
const [foldedCount, setFoldedCount] = useState(0)
const level = Math.min(foldedCount, stages.length)
const freedRef = useRef<{ stagesKey: string; widths: number[] }>({ stagesKey, widths: [] })
const pendingRef = useRef<(RowMeasure & { level: number }) | null>(null)
const checkRef = useRef<() => void>(() => {})
checkRef.current = () => {
if (freedRef.current.stagesKey !== stagesKey) {
freedRef.current = { stagesKey, widths: [] }
pendingRef.current = null
}
const measure = measureRow(rowRef.current)
if (!measure) {
return
}
const freed = freedRef.current.widths
const pending = pendingRef.current
if (pending && pending.level === level) {
freed[level - 1] = measure.address - pending.address + (pending.overflow - measure.overflow)
pendingRef.current = null
}
const shortfall =
Math.max(0, BROWSER_CHROME_ADDRESS_MIN_WIDTH_PX - measure.address) + measure.overflow
if (shortfall > 0.5 && level < stages.length) {
pendingRef.current = { ...measure, level: level + 1 }
setFoldedCount(level + 1)
return
}
const slack = measure.address - BROWSER_CHROME_ADDRESS_MIN_WIDTH_PX
if (shortfall <= 0.5 && level > 0 && slack >= (freed[level - 1] ?? 0)) {
setFoldedCount(level - 1)
}
}
// Why layout effect: each fold step re-measures before paint, so a squeeze never flashes clipped.
useLayoutEffect(() => {
checkRef.current()
}, [level, stagesKey])
useLayoutEffect(() => {
const row = rowRef.current
const slot = row?.querySelector(`[${BROWSER_CHROME_ADDRESS_SLOT_ATTRIBUTE}]`)
if (!row || typeof ResizeObserver === 'undefined') {
return
}
// Why both: the slot resizes when a sibling tool appears or hides, but stops resizing once it
// hits zero while the row keeps overflowing.
const observer = new ResizeObserver(() => checkRef.current())
observer.observe(row)
if (slot) {
observer.observe(slot)
}
return () => observer.disconnect()
}, [rowRef])
return new Set(stages.slice(0, level))
}
@@ -8,6 +8,10 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { translate } from '@/i18n/i18n'
import {
BrowserChromeFoldedMenuItems,
type BrowserChromeOverflowMenuProps
} from '../assemble-chrome/browser-chrome-folded-tools'
/**
* The preview's overflow menu. It carries the document actions rather than the browsing pane's
@@ -18,7 +22,8 @@ export function DocPreviewOverflowMenu({
onHardReload,
onOpenSource,
onCopyPath,
onCopyRelativePath
onCopyRelativePath,
overflow
}: {
onReload: () => void
onHardReload: () => void
@@ -26,11 +31,13 @@ export function DocPreviewOverflowMenu({
onCopyPath: () => void
/** Why it lives here: the preview hides the editor's path header, which was the only way to copy it. */
onCopyRelativePath: () => void
overflow: BrowserChromeOverflowMenuProps
}): React.JSX.Element {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
ref={overflow.triggerRef}
size="icon"
variant="ghost"
className="h-7 w-7"
@@ -42,7 +49,11 @@ export function DocPreviewOverflowMenu({
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuContent align="end" onCloseAutoFocus={overflow.onMenuCloseAutoFocus}>
<BrowserChromeFoldedMenuItems
tools={overflow.tools}
deferUntilClose={overflow.deferUntilClose}
/>
<DropdownMenuItem onSelect={onReload}>
<RefreshCw className="size-3.5" />
{translate('auto.components.browser.pane.BrowserPane.0e080d820e', 'Reload')}
@@ -102,7 +102,8 @@ export function DocPreviewToolbar({
label: translate(
'auto.components.editor.HtmlDocPreview.openSourceControl',
'Open source file'
)
),
alreadyInOverflowMenu: true
}}
openExternal={{
onSelect: onOpenExternally,
@@ -111,15 +112,16 @@ export function DocPreviewToolbar({
'Open with default app'
)
}}
overflowMenu={
overflowMenu={(overflow) => (
<DocPreviewOverflowMenu
onReload={onReload}
onHardReload={onHardReload}
onOpenSource={onOpenSource}
onCopyPath={onCopyPath}
onCopyRelativePath={onCopyRelativePath}
overflow={overflow}
/>
}
)}
/>
)
}