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:
@@ -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(
|
||||
|
||||
@@ -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: '选择要在工作栏中打开的应用。',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user