♻️ Keep sidebar focus handling out of ui-layout

This commit is contained in:
Saafo committed 2026-10-22 16:53:48 +08:00
1 parent 54daad316e
commit fdd520f901
11 files changed
+55 -53

No files matched your search

+1 -2
View File
@@ -56,8 +56,7 @@
"@deepseek-ai/dsh-client-ui-theme": "workspace:*",
"@deepseek-ai/cordis": "workspace:~",
"react": "^18.2.0",
"@deepseek-ai/dsh-client-shortcuts": "workspace:*",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:*"
"@deepseek-ai/dsh-client-shortcuts": "workspace:*"
},
"files": [
"locale/*.json",
@@ -16,7 +16,6 @@
*/
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { focusWithoutRing, isBehindModal } from '@deepseek-ai/dsh-client-ui-primitives'
import type {
HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore,
} from '@deepseek-ai/dsh-client-ui-slots'
@@ -138,8 +137,6 @@ export function AppFrame({
}: AppFrameProps) {
const layoutInfo = useStore(state => state.layoutInfo)
const frameRef = useRef<HTMLDivElement | null>(null)
const leadingSeat = useRef<HTMLDivElement>(null)
const sidebarFocus = useRef<HTMLElement | null>(null)
const viewport = layoutInfo.viewportWidth
const foldedLandscape = useFoldedLandscape(matches => matches)
@@ -176,11 +173,6 @@ export function AppFrame({
const centeredSidebar = foldedLandscape && viewport >= SIDEBAR_DRAWER_BREAKPOINT
const sidebarCollapsed = narrow ? !layoutInfo.narrowExpanded : layoutInfo.sidebar === 0
const drawerOpen = drawer && !sidebarCollapsed
useLayoutEffect(() => {
if (!drawer || !sidebarCollapsed || sidebarFocus.current !== document.activeElement) return
const opener = leadingSeat.current?.querySelector('button')
if (opener != null && !isBehindModal(opener)) focusWithoutRing(opener, { preventScroll: true })
}, [drawer, sidebarCollapsed])
// Entering drawer mode from a collapsed rail hides the backing immediately.
const [sidebarTransition, setSidebarTransition] = useState({ drawer, collapsed: sidebarCollapsed, instant: true })
if (sidebarTransition.drawer !== drawer || sidebarTransition.collapsed !== sidebarCollapsed) {
@@ -322,7 +314,6 @@ export function AppFrame({
{drawer && <div className={css.drawerMask} data-sidebar-mask aria-hidden="true"
onClick={drawerOpen ? () => { actions.toggleSidebar() } : undefined} />}
<div className={css.sidebarCol}
onFocusCapture={(event) => { if (event.target instanceof HTMLElement) sidebarFocus.current = event.target }}
style={drawer ? { width: sidebarWidth } : undefined}
{...{ inert: drawer && sidebarCollapsed ? '' : undefined }}>
{sidebar}
@@ -340,7 +331,7 @@ export function AppFrame({
{overlays}
</div>
{leadingMounted && (
<div ref={leadingSeat} className={css.leadingSeat} data-shell-leading {...{ inert: drawerOpen ? '' : undefined }}>
<div className={css.leadingSeat} data-shell-leading {...{ inert: drawerOpen ? '' : undefined }}>
{leading}
</div>
)}
@@ -1,7 +1,5 @@
// @vitest-environment jsdom
/** Frame interactions with a real store and explicitly driven browser measurements. */
import { useState } from 'react'
import { Menu, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
import type { GlobalStandardProps, RenderOpts } from '@deepseek-ai/dsh-client-ui-slots'
import { allowAllAccess, bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
@@ -798,25 +796,3 @@ describe('sidebar drawer', () => {
expect(tracks(frame)).toEqual([0, 0])
})
})
it.each(['menu', 'outside', 'dialog'] as const)('closes an obscured sidebar menu while preserving %s focus', (destination) => {
function Sidebar() {
const [open, setOpen] = useState(true)
return <><Menu open={open} onClose={() => { setOpen(false) }} portal
anchor={<button>View options</button>} items={[{ id: 'flat', label: 'Flat list' }]} />
{destination === 'dialog' && <Modal open title="Settings" closeLabel="Close" onClose={vi.fn()}><button>Settings control</button></Modal>}
</>
}
const view = mountFrame(1200, key => key === 'sidebar' ? <Sidebar />
: key === 'shell.leading' ? <button>Open navigation</button> : <button>Outside control</button>)
const menuItem = view.getByRole('menuitem')
let target: HTMLElement = menuItem
if (destination === 'outside') target = view.getAllByRole('button', { name: 'Outside control' })[0]!
if (destination === 'dialog') target = view.getByRole('button', { name: 'Settings control' })
target.focus()
resize(767)
act(flushFrames)
expect(view.queryByRole('menuitem')).toBeNull()
expect(document.activeElement).toBe(destination === 'menu'
? view.getByRole('button', { name: 'Open navigation' }) : target)
})
-4
View File
@@ -8,7 +8,6 @@
"src"
],
"references": [
{ "path": "../ui-primitives" },
{
"path": "../../util/brand"
},
@@ -24,9 +23,6 @@
{
"path": "../ui-theme/tsconfig.client.json"
},
{
"path": "../ui-primitives"
},
{
"path": "../store"
},
+2 -2
View File
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-client-ui-sidebar/use-this-package:
en: 90255789ba8051e5
zh: 8fa1d34539f58e2e
en: 13a63caa34a0061d
zh: a3e1953ed3ac4d8f
/deepseek-ai-dsh-client-ui-sidebar/use-this-package/brand-and-new-session:
en: f2c2254325cea5db
zh: c9ef643b431f8328
+1 -1
View File
@@ -29,7 +29,7 @@ The dsh web client sidebar lets users recognize the active build, start a new se
The sidebar is the navigation shell: users see the brand, start new sessions, collapse the rail, and reach Settings. Feature plugins fill its seats — ui-workspace fills `sidebar.workspaces`, ui-settings registers the trigger row and settings panel at `sidebar.settings`.
Below 768px, without a native Windows caption, the sidebar keeps its expanded content mounted inside the frame's drawer. The drawer initially focuses its collapse toggle, traps keyboard focus, closes with Escape or a mask press, and restores the invoking control unless a foreground dialog remains open. When an open drawer becomes a collapsed rail, focus transfers to the rail toggle if the invoking control unmounts and focus remains in the sidebar. Returning from a closed drawer renders the compact rail immediately, without a crossfade. Its toggle tooltip renders outside the drawer. Opening it does not resize the conversation.
Below 768px, without a native Windows caption, the sidebar keeps its expanded content mounted inside the frame's drawer. The drawer initially focuses its collapse toggle, traps keyboard focus, closes with Escape or a mask press, and restores the invoking control unless a foreground dialog remains open. When an open drawer becomes a collapsed rail, focus transfers to the rail toggle if the invoking control unmounts and focus remains in the sidebar. When a collapsed rail becomes a hidden drawer, the sidebar transfers its own focus to its header opener, preserving focus elsewhere and in foreground dialogs. Returning from a closed drawer renders the compact rail immediately, without a crossfade. Its toggle tooltip renders outside the drawer. Opening it does not resize the conversation.
### Brand and New Session
+1 -1
View File
@@ -29,7 +29,7 @@ dsh Web 客户端的侧边栏让用户识别当前构建、启动新会话、将
侧边栏是导航外壳:用户看到品牌、启动新会话、折叠轨道并到达 Settings。功能插件填充它的席位——ui-workspace 填充 `sidebar.workspaces`,ui-settings 在 `sidebar.settings` 注册触发行与设置面板。
宽度低于 768px 且没有原生 Windows 顶栏时,侧边栏在框架抽屉内保留展开内容。抽屉首先聚焦收起按钮,并限制键盘焦点范围,支持 Escape 和点击蒙层关闭,并在没有上层弹窗时将焦点还给打开它的控件。打开的抽屉切换为收起图标栏时,若打开入口已卸载且焦点仍在侧栏内,焦点转交给图标栏开关。关闭的抽屉切回图标栏时,立即使用紧凑布局,不播放交叉淡入淡出。开关的提示气泡在抽屉外渲染。打开抽屉不改变会话宽度。
宽度低于 768px 且没有原生 Windows 顶栏时,侧边栏在框架抽屉内保留展开内容。抽屉首先聚焦收起按钮,并限制键盘焦点范围,支持 Escape 和点击蒙层关闭,并在没有上层弹窗时将焦点还给打开它的控件。打开的抽屉切换为收起图标栏时,若打开入口已卸载且焦点仍在侧栏内,焦点转交给图标栏开关。收起的图标栏变为隐藏抽屉时,侧栏将自身焦点转交给顶部展开按钮,保留其他区域和上层弹窗的焦点。关闭的抽屉切回图标栏时,立即使用紧凑布局,不播放交叉淡入淡出。开关的提示气泡在抽屉外渲染。打开抽屉不改变会话宽度。
### 品牌与 New Session
@@ -30,6 +30,7 @@ export function HeaderLeadingControls({ toggleSidebar, startSession, trackEntryC
<div className={css.controls}>
<Tooltip label={t('toggle.open')} shortcutKeys={shortcut?.keys} delayMs={500}>
<button
data-sidebar-opener
type="button"
className={css.iconButton}
aria-label={t('toggle.open')}
@@ -16,11 +16,11 @@
* scrollbar indirection away while it is elsewhere, so a list the user is not
* pointing at carries no bar.
*/
import { useEffect, useRef, useState } from 'react'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import clsx from 'clsx'
import {
FishLogo, IconAppsAddOutlineRegular, IconNewChatOutlineMedium, IconNewChatOutlineRegular,
IconPanelLeftOutlineRegular, isDarwinDesktop, Menu, ShortcutKeys, Toast, Tooltip, useModalLayer,
IconPanelLeftOutlineRegular, focusWithoutRing, isBehindModal, isDarwinDesktop, Menu, ShortcutKeys, Toast, Tooltip, useModalLayer,
writeClipboard,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { InjectFace, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
@@ -187,7 +187,13 @@ export function SidebarRoot({
// leaves. A pointer that returns within that window cancels the pending
// hide rather than restarting from a hidden bar.
const column = useRef<HTMLDivElement>(null)
const sidebarFocus = useRef<Element | null>(null)
useModalLayer(column, drawer && !collapsed, toggleSidebar)
useLayoutEffect(() => {
if (!drawer || !collapsed || sidebarFocus.current !== document.activeElement) return
const opener = column.current?.ownerDocument.querySelector<HTMLElement>('[data-sidebar-opener]')
if (opener != null && !isBehindModal(opener)) focusWithoutRing(opener, { preventScroll: true })
}, [drawer, collapsed])
const [pointerInside, setPointerInside] = useState(false)
const lingerTimer = useRef<number | undefined>(undefined)
const armLinger = (): void => {
@@ -267,6 +273,7 @@ export function SidebarRoot({
return (
<div
ref={column}
onFocusCapture={(event) => { sidebarFocus.current = event.target }}
role={drawer ? 'dialog' : undefined}
data-shortcut-modal={drawer ? 'sidebar' : undefined}
aria-modal={drawer && !collapsed ? true : undefined}
@@ -2,10 +2,11 @@
import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
import type { ShortcutCatalogEntry, ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { Modal, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { Menu, Modal, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import { allowAllAccess, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import type { ReactNode } from 'react'
import { useState } from 'react'
import type {
SidebarFooterActionOwnerProps, SidebarRootComponentProps, SidebarSectionOwnerProps,
SidebarSettingsOwnerProps,
@@ -36,11 +37,12 @@ type AttentionSnapshot = Parameters<Parameters<SidebarRootComponentProps['useSes
const noAttention: AttentionSnapshot = new Map()
const useSessionStatus: SidebarRootComponentProps['useSessionStatus'] = selector => selector(noAttention)
function mountShell({ collapsed = false, width = 300, drawer = false, shortcuts = [] }: {
function mountShell({ collapsed = false, width = 300, drawer = false, shortcuts = [], content }: {
collapsed?: boolean
width?: number
drawer?: boolean
shortcuts?: readonly ShortcutCatalogEntry[]
content?: ReactNode
} = {}) {
const startSession = vi.fn()
const toggleSidebar = vi.fn()
@@ -78,7 +80,7 @@ function mountShell({ collapsed = false, width = 300, drawer = false, shortcuts
return <div data-testid="footer-action-seat" data-wide={owner.wide} />
}
regionOwner = owner as SidebarSectionOwnerProps
return <div data-testid="region" data-wide={owner.wide} />
return <div data-testid="region" data-wide={owner.wide}>{content}</div>
}) as SidebarRootComponentProps['renderSlot']}
/>
)
@@ -127,6 +129,7 @@ describe('SidebarRoot shell', () => {
usePanels={select => select([])} useEntryPins={select => select([])}
useEntryActions={select => select(false)} setPinned={vi.fn()} useShortcuts={select => select([shortcut])} />)
const button = screen.getByRole('button', { name: 'New session' })
expect(screen.getByRole('button', { name: 'Open sidebar' }).hasAttribute('data-sidebar-opener')).toBe(true)
expect(button.getAttribute('aria-keyshortcuts')).toBe('Control+N')
fireEvent.focus(button)
expect(Array.from(screen.getByRole('tooltip').querySelectorAll('kbd'), key => key.textContent)).toEqual(['Ctrl', 'N'])
@@ -576,3 +579,35 @@ it('closes a drawer opened from a focused SVG element', () => {
opener.remove()
}
})
it.each(['menu', 'outside', 'dialog'] as const)('closes an obscured sidebar menu while preserving %s focus', async (destination) => {
function Content() {
const [open, setOpen] = useState(true)
return <><Menu open={open} onClose={() => { setOpen(false) }} portal
anchor={<button>View options</button>} items={[{ id: 'flat', label: 'Flat list' }]} />
{destination === 'dialog' && <Modal open title="Settings" closeLabel="Close" onClose={vi.fn()}><button>Settings control</button></Modal>}
</>
}
render(<><button data-sidebar-opener>Open navigation</button><button>Outside control</button></>)
const h = mountShell({ collapsed: true, width: 56, content: <Content /> })
let target: HTMLElement = screen.getByRole('menuitem')
if (destination === 'outside') target = screen.getByRole('button', { name: 'Outside control' })
if (destination === 'dialog') target = screen.getByRole('button', { name: 'Settings control' })
target.focus()
screen.getByTestId('region').setAttribute('inert', '')
h.rerender({ drawer: true })
await waitFor(() => { expect(screen.queryByRole('menuitem')).toBeNull() })
expect(document.activeElement).toBe(destination === 'menu'
? screen.getByRole('button', { name: 'Open navigation' }) : target)
})
it.each([false, true])('moves hidden sidebar focus only to an available opener (%s)', (hasOpener) => {
render(<button data-sidebar-opener={hasOpener || undefined}>Open navigation</button>)
const h = mountShell({ collapsed: true, width: 56 })
const previous = screen.getByRole('button', { name: 'Open sidebar' })
previous.focus()
h.rerender({ drawer: true })
const expected = hasOpener ? screen.getByRole('button', { name: 'Open navigation' }) : previous
expect(document.activeElement).toBe(expected)
if (hasOpener) expect(expected.hasAttribute('data-dsh-automatic-focus')).toBe(true)
})
-3
View File
@@ -4607,9 +4607,6 @@ importers:
'@deepseek-ai/dsh-client-store':
specifier: workspace:*
version: link:../store
'@deepseek-ai/dsh-client-ui-primitives':
specifier: workspace:*
version: link:../ui-primitives
'@deepseek-ai/dsh-client-ui-renderer':
specifier: workspace:*
version: link:../ui-renderer