fix: refine workbar tab overflow controls

Keep the add button after the last tab in the scrollable strip. Show transparent floating scroll arrows on hover without reducing tab space, preserve keyboard and touch access, and reveal activated tabs.

Update tab interaction documentation and regression coverage. Exclude documentation demos from ESLint.

Validation: 66 focused tests, typecheck and lint passed. Full tests reported Agent package and runtime reuse failures before timeout; desktop visual verification remains pending.

Release note: Workbar tabs now keep the add button beside the last tab and reveal floating scroll arrows when hovering over an overflowing tab bar.
This commit is contained in:
mesalogo
2026-09-16 00:07:39 +08:00
parent f1d7197480
commit eaf71ce16f
7 changed files with 208 additions and 11 deletions
+70 -2
View File
@@ -1,4 +1,5 @@
import {
act,
cleanup,
fireEvent,
render,
@@ -23,7 +24,10 @@ import {
type WorkbarInstanceCreateRequest
} from './WorkbarShell'
afterEach(cleanup)
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
const terminalOne: WorkbarTabInstance = {
id: '20000000-0000-4000-8000-000000000001',
@@ -82,6 +86,70 @@ function ControlledShell({
}
describe('WorkbarShell', () => {
it('shows overflow controls, scrolls without switching tabs, and hides them after widening', () => {
let resize = (): void => {}
vi.stubGlobal('ResizeObserver', class {
constructor(callback: () => void) { resize = callback }
observe(): void {}
disconnect(): void {}
})
render(<ControlledShell />)
expect(screen.queryByRole('button', { name: '向左滚动标签栏' })).toBeNull()
const list = screen.getByRole('tablist')
const strip = list.parentElement!
const scroll = strip.parentElement!
const row = scroll.parentElement!
let rowWidth = 340
let viewportWidth = 230
let left = 0
Object.defineProperty(row, 'clientWidth', { get: () => rowWidth })
Object.defineProperty(strip, 'scrollWidth', { get: () => 600 })
Object.defineProperty(scroll, 'scrollWidth', { get: () => 600 })
Object.defineProperty(scroll, 'clientWidth', { get: () => viewportWidth })
Object.defineProperty(scroll, 'scrollLeft', {
get: () => left,
set: (value: number) => { left = Math.max(0, Math.min(600 - viewportWidth, value)) }
})
act(() => resize())
const previous = screen.getByRole('button', { name: '向左滚动标签栏' })
const next = screen.getByRole('button', { name: '向右滚动标签栏' })
expect(previous).toBeDisabled()
expect(next).toBeEnabled()
fireEvent.click(next)
expect(left).toBe(184)
expect(previous).toBeEnabled()
fireEvent.click(next)
fireEvent.click(next)
expect(next).toBeDisabled()
expect(screen.getByRole('tab', { name: '任务中心' })).toHaveAttribute('aria-selected', 'true')
fireEvent.click(previous)
expect(left).toBe(186)
expect(next).toBeEnabled()
left = 0
fireEvent.scroll(scroll)
expect(previous).toBeDisabled()
const add = screen.getByRole('button', { name: '打开工作栏应用' })
expect(add.parentElement).toBe(strip)
expect(list.nextElementSibling).toBe(add)
expect(row.firstElementChild).toBe(previous)
expect(row.lastElementChild).toBe(next)
rowWidth = 800
viewportWidth = 600
act(() => resize())
expect(screen.queryByRole('button', { name: '向右滚动标签栏' })).toBeNull()
})
it('reveals an activated tab outside the scroll viewport', () => {
render(<ControlledShell />)
const scroll = screen.getByRole('tablist').parentElement!.parentElement!
const workspace = screen.getByRole('tab', { name: '工作区' })
vi.spyOn(scroll, 'getBoundingClientRect').mockReturnValue({ left: 20, right: 220 } as DOMRect)
vi.spyOn(workspace.parentElement!, 'getBoundingClientRect').mockReturnValue({ left: 200, right: 320 } as DOMRect)
fireEvent.click(workspace)
expect(scroll.scrollLeft).toBe(100)
expect(workspace).toHaveAttribute('aria-selected', 'true')
})
it('keeps tabs and their controls rounded', () => {
expect(stylesheet).toMatch(
/\.workbar-shell__tab-item\s*\{[^}]*border-radius:\s*var\(--radius-control\) var\(--radius-control\) 0 0;/u
@@ -114,7 +182,7 @@ describe('WorkbarShell', () => {
expect(
screen.getByRole('button', { name: '打开工作栏应用' })
).toBeVisible()
expect(tablist.parentElement).toHaveClass(
expect(tablist.parentElement!.parentElement).toHaveClass(
'workbar-shell__tab-scroll'
)
expect(
+92 -3
View File
@@ -1,4 +1,6 @@
import {
ChevronLeft,
ChevronRight,
ClipboardCheck,
FileOutput,
FolderTree,
@@ -116,6 +118,14 @@ export function WorkbarShell({
const { t } = useTranslation('workspace')
const idPrefix = useId()
const addButtonRef = useRef<HTMLButtonElement>(null)
const tabRowRef = useRef<HTMLDivElement>(null)
const tabScrollRef = useRef<HTMLDivElement>(null)
const tabListRef = useRef<HTMLDivElement>(null)
const [tabOverflow, setTabOverflow] = useState({
visible: false,
left: false,
right: false
})
const catalogRef = useRef<HTMLDivElement>(null)
const returnInstanceIdRef = useRef<string | null>(null)
const pendingTabFocusRef = useRef<string | null | undefined>(undefined)
@@ -137,6 +147,59 @@ export function WorkbarShell({
const panelId = (instanceId: string): string =>
`${idPrefix}-panel-${instanceId}`
const updateTabOverflow = useCallback(() => {
const row = tabRowRef.current
const scroll = tabScrollRef.current
const list = tabListRef.current
if (!row || !scroll || !list) return
const style = getComputedStyle(row)
// Measure without the arrows so their appearance cannot sustain overflow.
const available = row.clientWidth -
(parseFloat(style.paddingLeft) || 0) -
(parseFloat(style.paddingRight) || 0)
const next = {
visible: list.scrollWidth > available + 1,
left: scroll.scrollLeft > 1,
right: scroll.scrollLeft + scroll.clientWidth < scroll.scrollWidth - 1
}
setTabOverflow((current) =>
current.visible === next.visible && current.left === next.left &&
current.right === next.right ? current : next
)
}, [])
useEffect(() => {
const observer = new ResizeObserver(updateTabOverflow)
for (const element of [tabRowRef.current, tabScrollRef.current, tabListRef.current]) {
if (element) observer.observe(element)
}
updateTabOverflow()
return () => observer.disconnect()
}, [updateTabOverflow])
useEffect(() => {
const scroll = tabScrollRef.current
const tab = activeInstanceId && !catalogOpen
? document.getElementById(tabId(activeInstanceId))?.parentElement
: null
if (scroll && tab) {
const viewport = scroll.getBoundingClientRect()
const bounds = tab.getBoundingClientRect()
const delta = bounds.left < viewport.left
? bounds.left - viewport.left
: Math.max(0, bounds.right - viewport.right)
if (delta) scroll.scrollLeft += delta
}
updateTabOverflow()
}, [activeInstanceId, catalogOpen, unorderedInstances, tabId, tabOverflow.visible, updateTabOverflow])
const scrollTabs = (direction: number): void => {
const scroll = tabScrollRef.current
if (!scroll) return
scroll.scrollLeft += direction * scroll.clientWidth * 0.8
updateTabOverflow()
}
useEffect(() => {
if (!catalogOpen) {
return
@@ -327,8 +390,21 @@ export function WorkbarShell({
aria-label={t('sidebar.workbar.ariaLabel')}
className={joinClassNames('workbar-shell', className)}
>
<div className="workbar-shell__tab-row">
<div className="workbar-shell__tab-scroll">
<div className="workbar-shell__tab-row" ref={tabRowRef}>
{tabOverflow.visible && (
<button
aria-label={t('sidebar.workbar.scrollLeft')}
title={t('sidebar.workbar.scrollLeft')}
className="workbar-shell__scroll-button"
disabled={!tabOverflow.left}
onClick={() => scrollTabs(-1)}
type="button"
>
<ChevronLeft aria-hidden="true" />
</button>
)}
<div className="workbar-shell__tab-scroll" ref={tabScrollRef} onScroll={updateTabOverflow}>
<div className="workbar-shell__tab-strip" ref={tabListRef}>
<div
aria-label={t('sidebar.workbar.tablist')}
className="workbar-shell__tablist"
@@ -406,7 +482,6 @@ export function WorkbarShell({
)
})}
</div>
</div>
<button
aria-expanded={catalogOpen}
aria-label={t('sidebar.workbar.add')}
@@ -423,6 +498,20 @@ export function WorkbarShell({
>
<span aria-hidden="true">+</span>
</button>
</div>
</div>
{tabOverflow.visible && (
<button
aria-label={t('sidebar.workbar.scrollRight')}
title={t('sidebar.workbar.scrollRight')}
className="workbar-shell__scroll-button"
disabled={!tabOverflow.right}
onClick={() => scrollTabs(1)}
type="button"
>
<ChevronRight aria-hidden="true" />
</button>
)}
</div>
<div className="workbar-shell__content">
@@ -230,6 +230,8 @@ export const workspace = {
ariaLabel: 'Workbar applications',
tablist: 'Open workbar applications',
add: 'Open workbar application',
scrollLeft: 'Scroll tabs left',
scrollRight: 'Scroll tabs right',
close: 'Close {{title}}',
catalogTitle: 'New workbar application',
catalogDescription: 'Choose an application to open in the workbar.',
@@ -224,6 +224,8 @@ export const workspace = {
ariaLabel: '工作栏应用',
tablist: '已打开的工作栏应用',
add: '打开工作栏应用',
scrollLeft: '向左滚动标签栏',
scrollRight: '向右滚动标签栏',
close: '关闭{{title}}',
catalogTitle: '新建工作栏应用',
catalogDescription: '选择要在工作栏中打开的应用。',
+38 -3
View File
@@ -3937,6 +3937,7 @@ button > svg {
}
.workbar-shell__tab-row {
position: relative;
display: flex;
min-width: 0;
min-height: 46px;
@@ -4127,8 +4128,31 @@ button > svg {
}
.workbar-shell__scroll-button {
flex: 0 0 28px;
margin-bottom: 5px;
position: absolute;
z-index: 2;
bottom: 5px;
background: transparent;
opacity: 0;
pointer-events: none;
transition: opacity var(--motion-fast) ease-out;
}
.workbar-shell__tab-row:hover .workbar-shell__scroll-button:not(:disabled),
.workbar-shell__scroll-button:focus-visible {
opacity: 1;
pointer-events: auto;
}
.workbar-shell__tab-row:hover .workbar-shell__scroll-button:disabled {
opacity: 0.4;
}
.workbar-shell__scroll-button:first-child {
left: 0;
}
.workbar-shell__scroll-button:last-child {
right: 0;
}
.workbar-shell__scroll-button:hover:not(:disabled) {
@@ -4137,8 +4161,19 @@ button > svg {
}
.workbar-shell__scroll-button:disabled {
opacity: 0.4;
cursor: default;
pointer-events: none;
}
@media (hover: none) {
.workbar-shell__scroll-button:not(:disabled) {
opacity: 1;
pointer-events: auto;
}
.workbar-shell__scroll-button:disabled {
opacity: 0.4;
}
}
.workbar-shell__content,