♻️ Keep sidebar focus handling out of ui-layout
This commit is contained in:
1 parent
54daad316e
commit
fdd520f901
11 files changed
+55
-53
No files matched your search
@@ -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)
|
||||
})
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
Generated
-3
@@ -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
|
||||
|
||||
Reference in new issue
Block a user