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
@@ -179,8 +179,8 @@ stdio fallback。首期远程终端随 SSH Channel 结束,不提供跨 SSH 或
- 已打开实例位于一个语义化 `tablist` 中。
- 每个实例使用独立 `tab` 和 `tabpanel`。
- “+”固定在 Tab 行末尾,但位于 `tablist` 之外。
- Tab 过多时实例区域单行横向滚动,“+”保持可见;不换行、不自动隐藏实例。
- 标签全部可见时隐藏左右滚动按钮,“+”紧随最后一个标签;溢出时两端显示左右箭头,点击滚动约一屏,边界方向禁用。“+”固定在右箭头外侧。切换或新增激活标签时将其滚入可见区域。未激活标签使用灰蓝底色和边框,激活标签保留亮底与强调色顶线。
- Tab 过多时实例区域单行横向滚动,不换行、不自动隐藏实例。“+”紧随最后一个标签并随标签一起滚动,滚到末尾时可见。
- 标签全部可见时隐藏左右滚动按钮;溢出时左右箭头透明浮动于工作区两端,不占标签布局宽度。鼠标移入标签栏时显示箭头,移出后隐藏;键盘聚焦箭头时显示,无悬停能力的设备保持可见。悬停箭头时显示轻微底色。点击滚动约一屏,边界方向禁用且不拦截下方标签的指针操作。切换或新增激活标签时将其滚入可见区域。未激活标签使用低对比度灰蓝底色和细边框,激活标签保留亮底与强调色顶线。
- 终端 Tab 默认使用递增标题,例如“终端 1”“终端 2”;状态栏持续显示项目、Host、
工作目录和 Shell。用户可以重命名终端。
- 任务中心、工作区、成果及新建终端的默认标题随界面语言切换,恢复布局时也使用当前语言。
@@ -560,7 +560,7 @@ Schema、假数据终端或不可用入口的中间产品。
- [x] 连续选择浏览器会创建页面、导航状态和元素引用相互独立的浏览器 Tab。
- [x] 连续选择终端会创建多个互相独立的终端 Tab。
- [ ] 关闭可关闭实例、切换和重排实例后,“+”仍保持可用。
- [ ] Tab 溢出时单行横向滚动,不隐藏“+”或把 Tab 换成下拉框。
- [ ] Tab 溢出时单行横向滚动,两端箭头固定,“+”跟随最后一个 Tab,滚到末尾可见,不把 Tab 换成下拉框。
- [x] 关闭可关闭的活动 Tab 后激活正确的相邻 Tab 并恢复焦点。
- [x] 当前 Tab 的背景、边框、顶部指示和字重清晰可辨;关闭按钮仅在悬停、焦点或无悬停
设备上出现,且不引发布局位移。
+1
View File
@@ -11,6 +11,7 @@ export default tseslint.config(
'.runtime-resources/**',
'coverage/**',
'dist/**',
'docs/**/*-demo.*',
'node_modules/**',
'out/**',
'shareserver/**'
+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,