From f6006c5547be792b1546dd7bd4dabfb5ff4cd881 Mon Sep 17 00:00:00 2001 From: Xinyu Yang Date: Wed, 23 Sep 2026 14:34:20 +0800 Subject: [PATCH] fix(shortcuts): finish modal toggles and automatic focus handling --- .../modal-layers.expected.md | 20 +++- apps/web/tests/shortcuts-desktop.e2e.ts | 106 +++++++++++++++--- apps/web/tests/shortcuts-panels.e2e.ts | 16 +++ apps/web/tests/shortcuts-workspace.e2e.ts | 8 ++ apps/web/tests/shortcuts.e2e.ts | 57 +++++++++- packages/client/shortcuts/src/client/dom.ts | 3 +- .../client/shortcuts/src/client/native.ts | 3 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../ui-conversation/src/client/apply.ts | 4 +- .../src/client/conversation/assembler.ts | 32 ++---- .../src/client/conversation/assembly.ts | 36 ++---- .../conversation-registry.client.spec.ts | 7 +- .../tests/stop-shortcut.client.spec.ts | 4 +- packages/client/ui-dockkit/README.i18n.yaml | 4 +- packages/client/ui-dockkit/README.md | 2 +- packages/client/ui-dockkit/README.zh.md | 2 +- .../ui-dockkit/src/components/TabMenu.tsx | 6 +- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 4 +- packages/client/ui-primitives/README.zh.md | 4 +- packages/client/ui-primitives/src/Menu.tsx | 26 +++-- .../client/ui-primitives/src/Modal.module.css | 4 + packages/client/ui-primitives/src/Modal.tsx | 2 +- packages/client/ui-primitives/src/focus.ts | 29 +++++ packages/client/ui-primitives/src/index.ts | 3 +- .../client/ui-primitives/src/useModalLayer.ts | 38 +++++-- .../ui-primitives/tests/atoms.client.spec.tsx | 2 + .../tests/modal-layer.client.spec.tsx | 44 +++++++- .../ui-settings-general/README.i18n.yaml | 4 +- packages/client/ui-settings-general/README.md | 2 +- .../client/ui-settings-general/README.zh.md | 2 +- .../src/client/SettingsRoot.module.css | 4 + .../src/client/SettingsRoot.tsx | 6 +- .../ui-settings-general/src/client/index.ts | 9 +- .../tests/settings-root.client.spec.tsx | 10 +- packages/client/ui-shortcuts/README.i18n.yaml | 4 +- packages/client/ui-shortcuts/README.md | 4 +- packages/client/ui-shortcuts/README.zh.md | 4 +- .../client/ui-shortcuts/src/client/Editor.tsx | 7 +- .../src/client/Reference.module.css | 3 +- .../ui-shortcuts/src/client/Reference.tsx | 16 ++- .../client/ui-shortcuts/src/client/index.ts | 9 +- .../tests/commands.client.spec.ts | 75 +++++++++++-- .../client/ui-sidebar-right/README.i18n.yaml | 4 +- packages/client/ui-sidebar-right/README.md | 2 +- packages/client/ui-sidebar-right/README.zh.md | 2 +- .../ui-sidebar-right/src/client/shortcuts.ts | 8 +- .../tests/focus.client.spec.ts | 29 ++++- packages/client/ui-theme/README.i18n.yaml | 4 +- packages/client/ui-theme/README.md | 2 + packages/client/ui-theme/README.zh.md | 2 + packages/client/ui-theme/src/styles/base.css | 5 + .../tests/focus-styles.client.spec.ts | 70 ++++++++++++ .../ui-trajectory/tests/views.client.spec.tsx | 1 + 56 files changed, 603 insertions(+), 163 deletions(-) create mode 100644 packages/client/ui-primitives/src/focus.ts create mode 100644 packages/client/ui-theme/tests/focus-styles.client.spec.ts diff --git a/apps/web/tests/expected/shortcuts-desktop/modal-layers.expected.md b/apps/web/tests/expected/shortcuts-desktop/modal-layers.expected.md index 33afa9e582..dbfac7af1a 100644 --- a/apps/web/tests/expected/shortcuts-desktop/modal-layers.expected.md +++ b/apps/web/tests/expected/shortcuts-desktop/modal-layers.expected.md @@ -1,12 +1,28 @@ # Modal layers { - "settingsOnTop": { + "settingsInitialFocus": "General", + "settingsAfterEnter": { "focused": "Settings", "foreground": "Settings" }, + "dialogsAfterSettingsToggle": 0, + "dialogsAfterReferenceToggle": 0, + "referenceBlocksSettings": { + "focused": "Keyboard shortcuts", + "foreground": "Keyboard shortcuts" + }, "referenceOnTop": { "focused": "Keyboard shortcuts", "foreground": "Keyboard shortcuts" - } + }, + "settingsAfterReferenceToggle": { + "focused": "Settings", + "foreground": "Settings" + }, + "settingsAfterClose": { + "focused": "Settings", + "foreground": "Settings" + }, + "closedWindows": 0 } diff --git a/apps/web/tests/shortcuts-desktop.e2e.ts b/apps/web/tests/shortcuts-desktop.e2e.ts index 1b8e0259fe..6c37845f6f 100644 --- a/apps/web/tests/shortcuts-desktop.e2e.ts +++ b/apps/web/tests/shortcuts-desktop.e2e.ts @@ -7,7 +7,7 @@ import { chromium } from 'playwright' import { expect, it } from 'vitest' import { SessionId } from '@deepseek-ai/dsh-session' import { initialShortcutConfig, parseShortcutDefinitions, parseShortcutEdit } from '@deepseek-ai/dsh-client-shortcuts/protocol' -import type { DesktopShortcutInput, DesktopShortcutsApi, ShortcutRevision } from '@deepseek-ai/dsh-client-shortcuts/protocol' +import type { DesktopShortcutInput, DesktopShortcutsApi, ShortcutCommandId, ShortcutRevision } from '@deepseek-ai/dsh-client-shortcuts/protocol' import { desktopKeybindings } from '../../desktop/src/keybindings.ts' import { compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, webSnapshotMode } from './scaffold.ts' import { writeComposerDraft } from './support.ts' @@ -126,10 +126,13 @@ it.each([ expect(await composer.innerText()).toBe('Desktop focus draft') await writeComposerDraft(page, composer, '') await expect.poll(() => composer.textContent()).toBe('') - await openReference() const dialog = page.getByRole('dialog', { name: 'Keyboard shortcuts', exact: true }) - await dialog.waitFor() const settings = page.getByRole('dialog', { name: 'Settings', exact: true }) + const automaticFocusStyle = () => page.evaluate(() => { + const style = getComputedStyle(document.activeElement!) + return { outline: style.outlineStyle, shadow: style.boxShadow } + }) + const noFocusRing = { outline: 'none', shadow: 'none' } const modalState = () => page.evaluate(() => { const name = (node: Element | null) => node?.getAttribute('aria-label') ?? document.getElementById(node?.getAttribute('aria-labelledby') ?? '')?.textContent ?? null @@ -138,28 +141,100 @@ it.each([ foreground: name(document.elementFromPoint(innerWidth / 2, innerHeight / 2)?.closest('[role="dialog"]') ?? null), } }) + const closeModal = async (name: 'Settings' | 'Keyboard shortcuts', source: 'keyboard' | 'menu' = 'keyboard') => { + if (source === 'menu') { + await page.evaluate((input) => { window.shortcutFixture.deliver(input) }, { + kind: 'menu', commandId: 'page.close' as ShortcutCommandId, revision: snapshot.revision, + } satisfies DesktopShortcutInput) + } else { + await deliverPrimary('KeyW') + } + await page.getByRole('dialog', { name, exact: true }).waitFor({ state: 'hidden' }) + expect(await page.evaluate(() => window.shortcutFixture.closedWindows)).toBe(0) + expect(await page.evaluate(() => document.activeElement?.hasAttribute('data-dsh-automatic-focus'))).toBe(true) + expect((await automaticFocusStyle()).outline).toBe('none') + } await deliverPrimary('Comma') await settings.waitFor() - const settingsOnTop = await modalState() - expect(settingsOnTop).toEqual({ focused: 'Settings', foreground: 'Settings' }) - await page.keyboard.press('Escape') + const selectedCategory = settings.locator('nav button[aria-current="true"]') + expect(await selectedCategory.evaluate(node => node === document.activeElement)).toBe(true) + expect(await automaticFocusStyle()).toEqual(noFocusRing) + const settingsShadow = await settings.evaluate(node => getComputedStyle(node).boxShadow) + const settingsInitialFocus = await selectedCategory.innerText() + await page.keyboard.press('Enter') + expect(await settings.isVisible()).toBe(true) + expect(await automaticFocusStyle()).toEqual(noFocusRing) + const settingsAfterEnter = await modalState() + expect(settingsAfterEnter).toEqual({ focused: 'Settings', foreground: 'Settings' }) + await page.keyboard.press('Tab') + expect((await automaticFocusStyle()).outline).not.toBe('none') + expect(await page.evaluate(() => Number.parseFloat(getComputedStyle(document.activeElement!).outlineWidth))).toBeGreaterThan(0) + await deliverPrimary('Comma') await settings.waitFor({ state: 'hidden' }) - expect(await dialog.isVisible()).toBe(true) - await page.keyboard.press('Escape') + expect(await page.evaluate(() => document.activeElement?.hasAttribute('data-dsh-automatic-focus'))).toBe(true) + expect((await automaticFocusStyle()).outline).toBe('none') + const dialogsAfterSettingsToggle = await page.getByRole('dialog').count() + expect(dialogsAfterSettingsToggle).toBe(0) + await deliverPrimary('Comma') + await settings.waitFor() + expect(await automaticFocusStyle()).toEqual(noFocusRing) + await closeModal('Settings') + expect(await dialog.isVisible()).toBe(false) + await openReference() + await dialog.waitFor() + expect(await dialog.getByRole('searchbox').evaluate(node => node === document.activeElement)).toBe(true) + expect(await automaticFocusStyle()).toEqual(noFocusRing) + const referenceShadow = await dialog.evaluate(node => getComputedStyle(node).boxShadow) + await deliverPrimary('Slash') await dialog.waitFor({ state: 'hidden' }) + expect(await page.evaluate(() => document.activeElement?.hasAttribute('data-dsh-automatic-focus'))).toBe(true) + expect((await automaticFocusStyle()).outline).toBe('none') + const dialogsAfterReferenceToggle = await page.getByRole('dialog').count() + expect(dialogsAfterReferenceToggle).toBe(0) + await openReference() + await dialog.waitFor() + await deliverPrimary('Comma') + expect(await settings.isVisible()).toBe(false) + expect(await dialog.isVisible()).toBe(true) + const referenceBlocksSettings = await modalState() + expect(referenceBlocksSettings).toEqual({ focused: 'Keyboard shortcuts', foreground: 'Keyboard shortcuts' }) + expect(await automaticFocusStyle()).toEqual(noFocusRing) + expect(await dialog.evaluate(node => getComputedStyle(node).boxShadow)).toBe(referenceShadow) + await closeModal('Keyboard shortcuts') + expect(await settings.isVisible()).toBe(false) await deliverPrimary('Comma') await settings.waitFor() await openReference() await dialog.waitFor() const referenceOnTop = await modalState() expect(referenceOnTop).toEqual({ focused: 'Keyboard shortcuts', foreground: 'Keyboard shortcuts' }) - await compareOrRefreshGolden(join(expected, 'modal-layers.expected.md'), - `# Modal layers\n\n${JSON.stringify({ settingsOnTop, referenceOnTop }, null, 2)}`, mode) - await page.keyboard.press('Escape') + expect(await automaticFocusStyle()).toEqual(noFocusRing) + expect(await settings.evaluate(node => getComputedStyle(node).boxShadow)).toBe(settingsShadow) + await deliverPrimary('Comma') + expect(await page.getByRole('dialog').count()).toBe(2) + expect(await settings.isVisible()).toBe(true) + expect(await modalState()).toEqual(referenceOnTop) + expect(await automaticFocusStyle()).toEqual(noFocusRing) + await deliverPrimary('Slash') await dialog.waitFor({ state: 'hidden' }) expect(await settings.isVisible()).toBe(true) - await page.keyboard.press('Escape') - await settings.waitFor({ state: 'hidden' }) + const settingsAfterReferenceToggle = await modalState() + expect(settingsAfterReferenceToggle).toEqual({ focused: 'Settings', foreground: 'Settings' }) + expect(await selectedCategory.getAttribute('data-dsh-automatic-focus')).toBe('') + expect(await automaticFocusStyle()).toEqual(noFocusRing) + await openReference() + await dialog.waitFor() + await closeModal('Keyboard shortcuts', 'menu') + expect(await settings.isVisible()).toBe(true) + const settingsAfterClose = await modalState() + expect(settingsAfterClose).toEqual({ focused: 'Settings', foreground: 'Settings' }) + expect(await automaticFocusStyle()).toEqual(noFocusRing) + expect(await selectedCategory.getAttribute('data-dsh-automatic-focus')).toBe('') + expect(await settings.evaluate(node => getComputedStyle(node).boxShadow)).toBe(settingsShadow) + await closeModal('Settings') + const closedWindows = await page.evaluate(() => window.shortcutFixture.closedWindows) + await compareOrRefreshGolden(join(expected, 'modal-layers.expected.md'), + `# Modal layers\n\n${JSON.stringify({ settingsInitialFocus, settingsAfterEnter, dialogsAfterSettingsToggle, dialogsAfterReferenceToggle, referenceBlocksSettings, referenceOnTop, settingsAfterReferenceToggle, settingsAfterClose, closedWindows }, null, 2)}`, mode) await openReference() await dialog.waitFor() const rowHeights = () => dialog.getByRole('listitem').evaluateAll(rows => rows.map(row => row.getBoundingClientRect().height)) @@ -169,11 +244,14 @@ it.each([ await expect.poll(() => page.evaluate(() => window.shortcutFixture.recording)).toBe(true) expect(await rowHeights()).toEqual(heights) const firstRecorder = dialog.getByRole('button', { name: 'Press a shortcut', exact: true }) - expect(await firstRecorder.evaluate(node => getComputedStyle(node).boxShadow)).toBe('none') + expect(await firstRecorder.evaluate(node => node === document.activeElement)).toBe(true) + expect(await automaticFocusStyle()).toEqual(noFocusRing) expect(await dialog.getByRole('button', { name: 'Cancel recording', exact: true }).count()).toBe(0) await compareOrRefreshGolden(join(expected, 'recording.expected.md'), await dialog.getByRole('group').ariaSnapshot(), mode) await page.keyboard.press(`${primary}+C`) await dialog.getByRole('group').waitFor({ state: 'hidden' }) + expect(await dialog.evaluate(node => node === document.activeElement)).toBe(true) + expect(await automaticFocusStyle()).toEqual({ ...noFocusRing, shadow: referenceShadow }) const saved: unknown = JSON.parse(await readFile(join(userData, 'keybindings.json'), 'utf8')) expect(saved).toMatchObject({ schemaVersion: 2, profiles: { [`desktop:${platform}`]: { 'session.new': { code: 'KeyC', modifiers: [platform === 'macos' ? 'meta' : 'control'] }, diff --git a/apps/web/tests/shortcuts-panels.e2e.ts b/apps/web/tests/shortcuts-panels.e2e.ts index 316cf5b67f..9f3bd76b10 100644 --- a/apps/web/tests/shortcuts-panels.e2e.ts +++ b/apps/web/tests/shortcuts-panels.e2e.ts @@ -82,6 +82,11 @@ describe.skipIf(process.platform === 'win32')('Web sidebar shortcuts', () => { expect(await composer.innerText()).toContain('T5 draft remains unsent') const eventCount = agent.session.seq const panel = page.locator('[data-sidebar-right-panel][data-sidebar-right-open]') + const paneAppearance = () => panel.locator('[data-dockkit-pane]').evaluateAll(panes => panes.map((pane) => { + const style = getComputedStyle(pane) + return { outline: style.outlineStyle, shadow: style.boxShadow } + })) + const plainPane = { outline: 'none', shadow: 'none' } for (const { label, previous } of [ { label: 'Browser', previous: undefined }, @@ -94,6 +99,7 @@ describe.skipIf(process.platform === 'win32')('Web sidebar shortcuts', () => { : panel.locator('[data-sidebar-terminal]') await openedPage.waitFor() const focusedAfterOpening = await panel.locator('[data-dockkit-pane]').evaluate(pane => pane.contains(document.activeElement)) + expect(await paneAppearance()).toEqual([plainPane]) await page.keyboard.press(`${primary}+Alt+W`) await expect.poll(() => page.locator('[data-sidebar-right-open]').count(), { message: label }).toBe(0) expect(focusedAfterOpening, label).toBe(true) @@ -104,6 +110,7 @@ describe.skipIf(process.platform === 'win32')('Web sidebar shortcuts', () => { await composer.click() await page.keyboard.press(`${primary}+Shift+,`) await panel.waitFor() + expect(await paneAppearance()).toEqual([plainPane]) expect(await panel.getByRole('button', { name: 'Collapse right sidebar' }).getAttribute('aria-keyshortcuts')).toBe(aria) await panel.getByRole('tab').focus() await page.keyboard.press(`${primary}+Shift+,`) @@ -138,6 +145,7 @@ describe.skipIf(process.platform === 'win32')('Web sidebar shortcuts', () => { await page.keyboard.press(`${primary}+Shift+,`) await expect.poll(() => panel.getByRole('tab', { name: /Browser/ }).count()).toBe(2) expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => document.activeElement === pane)).toBe(true) + expect(await paneAppearance()).toEqual([plainPane]) await page.keyboard.press(`${primary}+Alt+W`) await expect.poll(() => panel.getByRole('tab', { name: /Browser/ }).count()).toBe(1) await page.keyboard.press(`${primary}+Shift+,`) @@ -153,9 +161,11 @@ describe.skipIf(process.platform === 'win32')('Web sidebar shortcuts', () => { await page.keyboard.press(`${primary}+Shift+,`) await panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).waitFor() expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => document.activeElement === pane)).toBe(true) + expect(await paneAppearance()).toEqual([plainPane]) await page.keyboard.press(`${primary}+Shift+,`) expect(await panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).count()).toBe(1) expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => document.activeElement === pane)).toBe(true) + expect(await paneAppearance()).toEqual([plainPane]) await bind(page, primary, 'Toggle panel fullscreen', 'Workspace files') await composer.click() @@ -166,6 +176,7 @@ describe.skipIf(process.platform === 'win32')('Web sidebar shortcuts', () => { await expect.poll(() => filesTab.evaluate(element => document.activeElement === element)).toBe(true) await page.keyboard.press(`${primary}+Shift+,`) await page.locator('[data-sidebar-right-panel="fullscreen"]').waitFor() + expect(await paneAppearance()).toEqual([plainPane]) expect(await composer.innerText()).toContain('T5 draft remains unsent') expect(agent.session.seq).toBe(eventCount) @@ -175,6 +186,8 @@ describe.skipIf(process.platform === 'win32')('Web sidebar shortcuts', () => { await expect.poll(() => panel.locator('[data-dockkit-pane]').count()).toBe(2) const left = panel.locator('[data-dockkit-pane][data-dockkit-column="0"]') const right = panel.locator('[data-dockkit-pane][data-dockkit-column="1"]') + expect(await right.evaluate(pane => document.activeElement === pane)).toBe(true) + expect(await paneAppearance()).toEqual([plainPane, plainPane]) const disabled = right.locator('[data-dockkit-split-button]') expect(await disabled.isDisabled()).toBe(true) await disabled.locator('..').focus() @@ -241,6 +254,7 @@ describe.skipIf(process.platform === 'win32')('Web sidebar shortcuts', () => { expect(await right.getByRole('tab').count()).toBe(2) } expect(await menuTab.evaluate(tab => tab === document.activeElement)).toBe(true) + expect(await menuTab.evaluate(tab => getComputedStyle(tab).outlineStyle)).toBe('none') await (closeFirst === 'right' ? right : left).getByRole('tab').last().focus() await page.keyboard.press(`${primary}+Shift+,`) await expect.poll(() => panel.getByRole('tab').count()).toBe(2) @@ -248,6 +262,7 @@ describe.skipIf(process.platform === 'win32')('Web sidebar shortcuts', () => { await expect.poll(() => panel.getByRole('tab').count()).toBe(1) expect(await panel.locator('[data-dockkit-pane]').count()).toBe(1) expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => pane === document.activeElement)).toBe(true) + expect(await paneAppearance()).toEqual([plainPane]) await page.keyboard.press(`${primary}+Shift+,`) await expect.poll(() => page.locator('[data-sidebar-right-open]').count()).toBe(0) await page.getByRole('button', { name: 'Open right sidebar', exact: true }).click() @@ -258,6 +273,7 @@ describe.skipIf(process.platform === 'win32')('Web sidebar shortcuts', () => { await page.keyboard.press(`${primary}+Shift+,`) await expect.poll(() => panel.getByRole('tab').count()).toBe(1) expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => pane === document.activeElement)).toBe(true) + expect(await paneAppearance()).toEqual([plainPane]) await compareOrRefreshGolden(join(expected, 'guide-close.expected.md'), await captureStableAria(page, '[data-sidebar-right-panel]', scaffold.workspaceCwd), mode) await page.keyboard.press(`${primary}+Shift+,`) diff --git a/apps/web/tests/shortcuts-workspace.e2e.ts b/apps/web/tests/shortcuts-workspace.e2e.ts index 58a84096a2..f269d80e55 100644 --- a/apps/web/tests/shortcuts-workspace.e2e.ts +++ b/apps/web/tests/shortcuts-workspace.e2e.ts @@ -258,7 +258,15 @@ it.skipIf(mode === 'record').each([ await page.keyboard.press(`${primary}+Alt+K`) const search = page.getByPlaceholder('Search session names', { exact: true }) await expect.poll(() => search.evaluate(element => element === document.activeElement)).toBe(true) + const searchAppearance = await search.evaluate((element) => { + const input = getComputedStyle(element) + const surface = getComputedStyle(element.parentElement!) + return { outline: input.outlineStyle, shadow: input.boxShadow, + surfaceOutline: surface.outlineStyle, surfaceShadow: surface.boxShadow } + }) + expect(searchAppearance).toEqual({ outline: 'none', shadow: 'none', surfaceOutline: 'none', surfaceShadow: 'none' }) await page.keyboard.press('Escape') + expect(await search.evaluate(element => getComputedStyle(element).boxShadow)).toBe(searchAppearance.shadow) await page.keyboard.press(`${primary}+Alt+B`) await page.getByRole('button', { name: 'Open sidebar', exact: true }).waitFor() await page.keyboard.press(`${primary}+Alt+B`) diff --git a/apps/web/tests/shortcuts.e2e.ts b/apps/web/tests/shortcuts.e2e.ts index 807dab594a..9c63649613 100644 --- a/apps/web/tests/shortcuts.e2e.ts +++ b/apps/web/tests/shortcuts.e2e.ts @@ -52,7 +52,18 @@ describe('web e2e: shortcut reference', () => { .toEqual(new Set([42])) expect(await rows.evaluateAll(items => items.every(row => row.getBoundingClientRect().height >= 42 && row.scrollWidth <= row.clientWidth))).toBe(true) - expect(await page.getByRole('searchbox', { name: search }).evaluate(element => element === document.activeElement)).toBe(true) + const searchInput = page.getByRole('searchbox', { name: search }) + expect(await searchInput.evaluate(element => element === document.activeElement)).toBe(true) + expect(await searchInput.evaluate(element => ({ + outline: getComputedStyle(element).outlineStyle, shadow: getComputedStyle(element).boxShadow, + }))).toEqual({ outline: 'none', shadow: 'none' }) + const searchSurfaceStyle = () => dialog.getByRole('search').evaluate((element) => { + const style = getComputedStyle(element) + return { border: style.borderColor, outline: style.outlineStyle, shadow: style.boxShadow } + }) + const focusedSearchSurface = await searchSurfaceStyle() + expect(focusedSearchSurface).toMatchObject({ outline: 'none', shadow: 'none' }) + const dialogShadow = await dialog.evaluate(element => getComputedStyle(element).boxShadow) await compareOrRefreshGolden(join(expected, `${locale}.expected.md`), await captureStableAria(page, '[data-shortcut-modal="shortcuts"]', scaffold.workspaceCwd), mode) const fixedButtons = dialog.getByRole('button', { disabled: true }) @@ -80,6 +91,7 @@ describe('web e2e: shortcut reference', () => { expect(await badgeColor()).toBe(restingColor) await badge.click() await boundRow.getByRole('group').waitFor() + expect(await searchSurfaceStyle()).toEqual(focusedSearchSurface) expect(new Set(await rows.evaluateAll(items => items.map(row => row.getBoundingClientRect().height)))) .toEqual(new Set([42])) expect(await boundRow.getByRole('button', { name: locale === 'zh-CN' ? '移除' : 'Remove', exact: true }).count()).toBe(1) @@ -89,8 +101,11 @@ describe('web e2e: shortcut reference', () => { const boundActionsOpacity = () => boundRow.getByRole('button').first().evaluate(element => getComputedStyle(element.parentElement!).opacity) expect(await boundActionsOpacity()).toBe('0') expect(await dialog.evaluate(element => element === document.activeElement)).toBe(true) + expect(await dialog.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none') + expect(await dialog.evaluate(element => getComputedStyle(element).boxShadow)).toBe(dialogShadow) await page.keyboard.press('Shift+Tab') expect(await dialog.getByRole('button', { disabled: false }).last().evaluate(element => element === document.activeElement)).toBe(true) + expect(await dialog.getByRole('button', { disabled: false }).last().evaluate(element => getComputedStyle(element).outlineStyle)).not.toBe('none') await badge.click() await boundRow.getByRole('button', { name: locale === 'zh-CN' ? '恢复默认' : 'Restore default', exact: true }).click() await boundRow.getByRole('group').waitFor({ state: 'hidden' }) @@ -105,6 +120,10 @@ describe('web e2e: shortcut reference', () => { expect(await inline.getByRole('button', { name: locale === 'zh-CN' ? '移除' : 'Remove', exact: true }).count()).toBe(1) expect(await page.getByRole('dialog').count()).toBe(2) expect(await recorder.evaluate(element => getComputedStyle(element).color)).toBe('rgb(129, 133, 140)') + expect(await recorder.evaluate(element => ({ + focused: element === document.activeElement, + outline: getComputedStyle(element).outlineStyle, shadow: getComputedStyle(element).boxShadow, + }))).toEqual({ focused: true, outline: 'none', shadow: 'none' }) await compareOrRefreshGolden(join(expected, `${locale}-inline.expected.md`), await captureStableAria(page, '[data-shortcut-modal="shortcut-edit"]', scaffold.workspaceCwd), mode) await page.keyboard.press(referenceKey) @@ -126,11 +145,14 @@ describe('web e2e: shortcut reference', () => { await page.keyboard.press(`${key}+Shift+.`) await inline.waitFor({ state: 'hidden' }) expect(await dialog.evaluate(element => element === document.activeElement)).toBe(true) + expect(await dialog.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none') + expect(await dialog.evaluate(element => getComputedStyle(element).boxShadow)).toBe(dialogShadow) expect(await editButton.evaluate(element => element.matches(':focus-visible'))).toBe(false) await dialog.getByRole('heading', { name: title, exact: true }).hover() expect(await editButton.evaluate(element => getComputedStyle(element.parentElement!).opacity)).toBe('0') await page.keyboard.press('Shift+Tab') expect(await resetAll.evaluate(element => element === document.activeElement)).toBe(true) + expect(await resetAll.evaluate(element => getComputedStyle(element).outlineStyle)).not.toBe('none') const successToast = page.getByRole('alert').filter({ hasText: locale === 'zh-CN' ? '已修改' : 'Modified' }) await successToast.waitFor() await compareOrRefreshGolden(join(expected, `${locale}-saved.expected.md`), await successToast.ariaSnapshot(), mode) @@ -151,6 +173,8 @@ describe('web e2e: shortcut reference', () => { await page.keyboard.press('Escape') await inline.waitFor({ state: 'hidden' }) expect(await dialog.evaluate(element => element === document.activeElement)).toBe(true) + expect(await dialog.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none') + expect(await dialog.evaluate(element => getComputedStyle(element).boxShadow)).toBe(dialogShadow) await dialog.getByRole('heading', { name: title, exact: true }).hover() expect(await editButton.evaluate(element => getComputedStyle(element.parentElement!).opacity)).toBe('0') expect(await page.getByRole('dialog').count()).toBe(2) @@ -166,19 +190,36 @@ describe('web e2e: shortcut reference', () => { expect(await dialog.getByRole('listitem').count()).toBe(1) expect(await dialog.getByRole('listitem').textContent()).toContain(locale === 'zh-CN' ? '展开/收起左侧栏' : 'Toggle left sidebar') } - await page.keyboard.press(referenceKey) + await page.keyboard.press(`${key}+Comma`) expect(await referenceNode!.evaluate(element => element.isConnected)).toBe(true) expect(await page.getByRole('dialog').count()).toBe(2) expect(await page.getByRole('searchbox').inputValue()).toBe('toggle left sidebar') - await page.keyboard.press('Escape') + expect(await searchInput.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none') + expect(await searchSurfaceStyle()).toEqual(focusedSearchSurface) + await page.keyboard.press(referenceKey) + await dialog.waitFor({ state: 'hidden' }) expect(await page.getByRole('dialog').count()).toBe(1) expect(await page.getByRole('button', { name: view, exact: true }).evaluate(element => element === document.activeElement)).toBe(true) - await page.keyboard.press('Escape') + expect(await page.getByRole('button', { name: view, exact: true }).getAttribute('data-dsh-automatic-focus')).toBe('') + expect(await page.getByRole('button', { name: view, exact: true }).evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none') + await page.keyboard.press(`${key}+Comma`) + await page.getByRole('dialog', { name: settings, exact: true }).waitFor({ state: 'hidden' }) expect(await page.getByRole('dialog').count()).toBe(0) expect(await trigger.evaluate(element => element === document.activeElement)).toBe(true) + expect(await trigger.getAttribute('data-dsh-automatic-focus')).toBe('') + expect(await trigger.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none') await page.keyboard.press(referenceKey) await dialog.waitFor() + await page.keyboard.press(`${key}+Comma`) + expect(await page.getByRole('dialog', { name: settings, exact: true }).isVisible()).toBe(false) + expect(await page.getByRole('dialog').count()).toBe(1) + expect(await searchInput.evaluate(element => element === document.activeElement)).toBe(true) + expect(await searchInput.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none') await page.keyboard.press(`${key}+/`) + await dialog.waitFor({ state: 'hidden' }) + expect(await page.getByRole('dialog').count()).toBe(0) + await page.keyboard.press(referenceKey) + await dialog.waitFor() expect(await page.getByRole('dialog').count()).toBe(1) expect(await page.getByRole('searchbox').inputValue()).toBe('') await page.setViewportSize({ width: 420, height: 500 }) @@ -206,6 +247,9 @@ describe('web e2e: shortcut reference', () => { await boundRow.getByText(locale === 'zh-CN' ? '暂无快捷键' : 'No shortcut', { exact: true }).waitFor() await page.keyboard.press('Escape') await dialog.waitFor({ state: 'hidden' }) + expect(await viewButton.evaluate(element => element === document.activeElement)).toBe(true) + expect(await viewButton.getAttribute('data-dsh-automatic-focus')).toBe('') + expect(await viewButton.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none') await viewButton.hover() expect(await page.getByRole('tooltip').count()).toBe(0) expect(await viewButton.getAttribute('aria-keyshortcuts')).toBeNull() @@ -225,9 +269,12 @@ describe('web e2e: shortcut reference', () => { const confirm = page.getByRole('dialog', { name: locale === 'zh-CN' ? '恢复全部默认快捷键?' : 'Restore all default shortcuts?', exact: true }) const cancel = confirm.getByRole('button', { name: locale === 'zh-CN' ? '取消' : 'Cancel', exact: true }) expect(await cancel.evaluate(element => element === document.activeElement)).toBe(true) + expect(await cancel.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none') await compareOrRefreshGolden(join(expected, `${locale}-reset-confirm.expected.md`), await confirm.ariaSnapshot(), mode) await cancel.click() expect(await resetAll.evaluate(element => element === document.activeElement)).toBe(true) + expect(await resetAll.getAttribute('data-dsh-automatic-focus')).toBe('') + expect(await resetAll.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none') expect(await dialog.locator('footer').textContent()).toContain(locale === 'zh-CN' ? '1 项已自定义' : '1 customized') await resetAll.click() await confirm.getByRole('button', { name: locale === 'zh-CN' ? '恢复默认' : 'Restore default', exact: true }).click() @@ -235,6 +282,8 @@ describe('web e2e: shortcut reference', () => { expect(await resetAll.isDisabled()).toBe(true) expect(await dialog.locator('footer').textContent()).toContain(locale === 'zh-CN' ? '0 项已自定义' : '0 customized') expect(await page.getByRole('searchbox').evaluate(element => element === document.activeElement)).toBe(true) + expect(await searchInput.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none') + expect(await searchSurfaceStyle()).toEqual(focusedSearchSurface) await page.keyboard.press('Escape') await page.keyboard.press('Escape') await page.keyboard.press(referenceKey) diff --git a/packages/client/shortcuts/src/client/dom.ts b/packages/client/shortcuts/src/client/dom.ts index 36872fd89a..753646fed6 100644 --- a/packages/client/shortcuts/src/client/dom.ts +++ b/packages/client/shortcuts/src/client/dom.ts @@ -1,5 +1,5 @@ /** Main-document keyboard adapter; local controls arbitrate before window bubbling. */ -import { observeComposition } from '@deepseek-ai/dsh-client-ui-primitives' +import { modalSelector, observeComposition } from '@deepseek-ai/dsh-client-ui-primitives' import type { ShortcutContext, ShortcutFixedInput } from './types.ts' import type { ShortcutRegistry } from './registry.ts' import type { ShortcutPlatform, ShortcutRuntime } from '../protocol.ts' @@ -37,7 +37,6 @@ export function installKeyboard(window: Window, shortcuts: Pick { fixed?.({ type: 'reset' }) } let deadKey = false const blur = (): void => { deadKey = false; reset() } - const modalSelector = '[role="dialog"][aria-modal="true"], [role="menu"]' const containsModal = (node: Node): boolean => node instanceof Element && (node.matches(modalSelector) || node.querySelector(modalSelector) !== null) const changedModals = (records: MutationRecord[]): void => { diff --git a/packages/client/shortcuts/src/client/native.ts b/packages/client/shortcuts/src/client/native.ts index faf162f63c..ff2c97f311 100644 --- a/packages/client/shortcuts/src/client/native.ts +++ b/packages/client/shortcuts/src/client/native.ts @@ -1,4 +1,5 @@ /** Desktop gestures use live focus, modal state, and verified embedding ownership. */ +import { modalSelector } from '@deepseek-ai/dsh-client-ui-primitives' import type { DesktopKeyboardApi, ShortcutConfigSnapshot } from '../protocol.ts' import type { ShortcutRegistry } from './registry.ts' @@ -20,7 +21,7 @@ export function installNativeKeyboard(window: Window, keyboard: DesktopKeyboardA while (target?.shadowRoot?.activeElement != null && !target.matches('webview[data-sidebar-browser-frame]')) { target = target.shadowRoot.activeElement } - const top = [...window.document.querySelectorAll('[role="dialog"][aria-modal="true"], [role="menu"]')].at(-1) + const top = [...window.document.querySelectorAll(modalSelector)].at(-1) const region = target?.closest('.xterm') ? 'terminal' as const : target?.matches('input, textarea, select, [contenteditable="true"], [contenteditable=""]') ? 'editable' as const : 'page' as const const context = { target, region, modal: top?.dataset.shortcutModal ?? (top === undefined ? null : 'other') } diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 011735f0c8..7a78efe23c 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 1da0b8a5c73f6cd5602b39dcb747f2939c63915a -README.zh.md: 3452ee74213d06541e4cc7cc31d1178ff57a3f68 +README.md: 2ee80ef18514ec0e6ecae1c08367839d7a3330a8 +README.zh.md: dd8d2ef628045cc63876d491f233c2179556917b diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 1da0b8a5c7..2ee80ef185 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -27,7 +27,7 @@ English | [中文](README.zh.md) `UiConversation.events` is the single registry for event Definitions, and `UiConversation.views` is the single registry for target snapshot builders. Both registries reject duplicate keys, preserve registration order, return idempotent disposers, and rebuild existing bindings when their contribution roster changes. `UiConversation.binding(bindingOrSessionId)` returns one identity-stable Conversation binding for the current Session Controller binding. It does not open another event source. A View Definition may declare `toolCallFocus` to translate a tool-call id into its focus identity. Conversation supplies an Inspect callback only while such a target has a visible View entry; Chat consumes this callback without selecting a target. -Fixed stop input reads a package-internal current-turn source from the existing assembly. Observed turn changes publish synchronously even when no View is active; a turn is available only after its start is loaded. Unloading the Session binding detaches its event-source subscription. +`ConversationBinding.openTurn` is an identity-stable, read-only source of the latest turn number when its start is loaded and it remains open, or `undefined` otherwise. Observed turn changes publish synchronously even when no View is active. Fixed stop input reads this source alongside Session running and pending-interaction state. Unloading the Session binding detaches its event-source subscription. The adapter passes each `SessionEventLikeEntry` directly to the assembler. Its outer `type` distinguishes durable events from Client-only transient events, while its inner `event` always exposes `type`, `seq`, `time`, and `data`; Definitions receive that inner `SessionEventLike`. Replacement windows may include both entry variants, while historical prepends carry durable entries and live appends may carry either. Every Definition uses the same `match` and `update` methods for both event forms, while `start` receives only a durable event and the assembler rejects a transient start. Definitions that do not consume Assistant deltas return `null` for `assistant/live-chunk`. Replacement windows and revision gaps rebuild from the complete loaded window; contiguous append, prepend, and Assistant-settlement revisions use incremental assembly. Settlement removes only the named attempt's transient matches, applies its optional durable entry, and replays the affected Contexts and dependents without replacing unrelated target nodes. The assembler owns Context matching, Turn/Step locations, target node materialization, target activity, and stable target sources. `ConversationSnapshot` contains only target-neutral views and active-target facts; Session lifecycle state remains in `SessionSnapshot`. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 3452ee7421..dd8d2ef628 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" `UiConversation.events` 是 event Definition 的唯一 registry,`UiConversation.views` 是 target snapshot builder 的唯一 registry。两者都拒绝重复 key、保持注册顺序、返回幂等 disposer,并在 contribution roster 变化时重建现有 binding。`UiConversation.binding(bindingOrSessionId)` 为当前 Session Controller binding 返回 identity 稳定的 Conversation binding,不会另开事件源。 View Definition 可以声明 `toolCallFocus`,将工具调用 id 转换为自身的焦点标识。仅当此目标拥有可见的 View 条目时,Conversation 才提供 Inspect 回调;Chat 直接使用回调,不选择目标。 -固定停止输入从现有组装层读取包内的当前轮次源。观察到的轮次变化同步发布,即使没有活动视图也是如此;只有加载到轮次开始事件后才提供该轮次。卸载会话绑定会解除其事件源订阅。 +`ConversationBinding.openTurn` 是对象标识稳定的只读来源:最新轮次的开始事件已加载且尚未结束时提供其编号,否则为 `undefined`。观察到的轮次变化同步发布,即使没有活动视图也是如此。固定停止输入同时读取这个来源、会话运行状态和待处理交互状态。卸载会话绑定会解除其事件源订阅。 适配器把每个 `SessionEventLikeEntry` 直接交给 assembler。外层 `type` 区分持久事件与 Client-only transient event,内部 `event` 则统一公开 `type`、`seq`、`time` 与 `data`;Definition 接收这个内部 `SessionEventLike`。replacement window 可以包含两种 entry,历史 prepend 携带持久 entry,实时 append 则可以携带任一种。两种事件都使用 Definition 的同一组 `match` 与 `update` 方法,`start` 只接收持久 event,assembler 会拒绝 transient start。不消费 Assistant delta 的 Definition 对 `assistant/live-chunk` 返回 `null`。replace window 或 revision 断档从完整已加载窗口重建;连续 revision 的 append、prepend 与 Assistant settlement 使用增量组装。settlement 只删除具名 attempt 的 transient match,应用可选持久 entry,并重放受影响的 Context 及其 dependent,不替换无关 target node。assembler 拥有 Context 匹配、Turn/Step location、target node 物化、target activity 和稳定 target source。`ConversationSnapshot` 只包含与 target 无关的 View 与 active-target 事实;Session lifecycle 状态仍属于 `SessionSnapshot`。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index ef1e16a6a0..a4fc7eed43 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -12,7 +12,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type {} from '@deepseek-ai/dsh-client-ui-settings/client' import type { ShortcutCommandId, ShortcutFixedCommand } from '@deepseek-ai/dsh-client-shortcuts/client' -import { conversationOpenTurn, UiConversation } from './conversation/assembly.ts' +import { UiConversation } from './conversation/assembly.ts' import type { ViewTab } from './contract/views.ts' import type { ComposerBarInjected, ConversationInjected, ConversationSessionHeaderInjected, @@ -252,7 +252,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { scope.effect(() => scope.shortcuts.registerFixed(command), `ui-conversation: ${command.id}`) } scope.effect(() => installStopShortcut( - scope.shortcuts, sessions, binding => conversationOpenTurn(uiConversation, binding), ctx.uiSession, stop, + scope.shortcuts, sessions, binding => uiConversation.binding(binding).openTurn, ctx.uiSession, stop, ), 'ui-conversation: fixed stop input') scope.effect(() => { const command: ShortcutFixedCommand = { diff --git a/packages/client/ui-conversation/src/client/conversation/assembler.ts b/packages/client/ui-conversation/src/client/conversation/assembler.ts index 32d36f4765..821fb27672 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembler.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembler.ts @@ -173,18 +173,6 @@ export interface ConversationGroupDefinitions { const NO_GROUPS: ConversationGroupDefinitions = { entries: () => [], forTarget: () => undefined } -// The class initializes package-internal read access to its private location index. -let readOpenTurn: (assembler: ConversationNodeAssembler) => number | undefined - -/** - * Read the current open turn without activating a View. - * @param assembler - Session-owned incremental assembler. - * @returns the latest turn number when its start is loaded and it remains open, otherwise undefined. - */ -export function assemblerOpenTurn(assembler: ConversationNodeAssembler): number | undefined { - return readOpenTurn(assembler) -} - /** * Session-owned incremental engine that assembles business Contexts from a * contiguous Event window and materializes registered view snapshots. @@ -208,15 +196,6 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore private replacePending = true private timelineDirty = true - static { - readOpenTurn = (assembler) => { - const snapshot = assembler.locationIndex.snapshot() - const latest = snapshot.turnOrder.at(-1) - const turn = latest === undefined ? undefined : snapshot.turns.get(latest) - return turn?.status === 'open' && turn.start !== undefined ? turn.turn : undefined - } - } - /** * @param eventDefinitions - live Event Definition registry. * @param viewDefinitions - live view builder registry. @@ -230,6 +209,17 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore this.resetViewBuilders() } + /** + * Read the current open turn without activating a View. + * @returns the latest turn number when its start is loaded and it remains open, otherwise undefined. + */ + openTurn(): number | undefined { + const snapshot = this.locationIndex.snapshot() + const latest = snapshot.turnOrder.at(-1) + const turn = latest === undefined ? undefined : snapshot.turns.get(latest) + return turn?.status === 'open' && turn.start !== undefined ? turn.turn : undefined + } + /** * Replace the complete loaded window after open, resync, or gap repair. * @param entries - complete contiguous window. diff --git a/packages/client/ui-conversation/src/client/conversation/assembly.ts b/packages/client/ui-conversation/src/client/conversation/assembly.ts index 2a9d8bdfa3..edc182f530 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembly.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembly.ts @@ -19,7 +19,7 @@ import type { } from '../contract/request-inspection.ts' import { inspectRequestPrompt } from '../contract/request-inspection.ts' import { inspectSystemPrompt, type SystemPromptState } from '../contract/system-prompt.ts' -import { assemblerOpenTurn, ConversationNodeAssembler } from './assembler.ts' +import { ConversationNodeAssembler } from './assembler.ts' import { ConversationEventRegistry } from './event-registry.ts' import { HistoricalImageCache } from './historical-images.ts' import { ConversationViewRegistry } from './view-registry.ts' @@ -28,6 +28,11 @@ import { ConversationGroupRegistry } from './group-registry.ts' /** Observable faces published for one Session's Conversation assembly. */ export interface ConversationBinding { readonly snapshot: ObservableSnapshot + /** + * Identity-stable source of the latest turn number, undefined unless its start is loaded and it remains open. + * Turn changes publish synchronously, including without an active View. + */ + readonly openTurn: ObservableSnapshot /** * Add one selected target to the Session's monotonic active set. * @param target - registered or subsequently registered Conversation target. @@ -60,7 +65,7 @@ class BoundConversation implements ConversationBinding { ) { this.viewStore = assembler this.snapshot = createSnapshotStore(this.currentSnapshot()) - this.openTurn = createSnapshotStore(assemblerOpenTurn(assembler)) + this.openTurn = createSnapshotStore(assembler.openTurn()) this.replace(feed.getSnapshot()) this.disposeFeed = feed.subscribe(() => { this.accept(feed.getSnapshot()) @@ -88,7 +93,7 @@ class BoundConversation implements ConversationBinding { activate(target: string): void { if (this.assembler.activateTarget(target)) this.snapshot.set(this.currentSnapshot()) - this.openTurn.set(assemblerOpenTurn(this.assembler)) + this.openTurn.set(this.assembler.openTurn()) } rebuild(): void { this.publish(this.assembler.rebuildRegistry()) } @@ -160,7 +165,7 @@ class BoundConversation implements ConversationBinding { private flush(): void { if (this.assembler.flush()) this.snapshot.set(this.currentSnapshot()) - this.openTurn.set(assemblerOpenTurn(this.assembler)) + this.openTurn.set(this.assembler.openTurn()) } private currentSnapshot(): ConversationSnapshot { @@ -177,20 +182,6 @@ interface BindingRecord { disposeScope: () => void } -// The class initializes package-internal access without exposing it on the service or public binding. -let resolveOpenTurn: (conversation: UiConversation, binding: SessionBinding) => ObservableSnapshot - -/** - * Resolve the open-turn source owned by one live Session binding. - * @param conversation - Conversation assembly owner. - * @param binding - active Session Controller binding. - * @returns stable source publishing turn changes synchronously, including without an active View. - * @throws if the binding is no longer current for its Session. - */ -export function conversationOpenTurn(conversation: UiConversation, binding: SessionBinding): ObservableSnapshot { - return resolveOpenTurn(conversation, binding) -} - /** Root service owning Conversation registries and per-Session bindings. */ export class UiConversation extends Service { /** Registry of event matchers and target snapshot builders. */ @@ -202,10 +193,6 @@ export class UiConversation extends Service { private readonly bindings = new WeakMapWithValues() private readonly images: HistoricalImageCache - static { - resolveOpenTurn = (conversation, binding) => conversation.resolveBinding(binding).openTurn - } - /** * @param ctx - owning Client context. * @param sessions - Session Controller object layer. @@ -245,12 +232,9 @@ export class UiConversation extends Service { * Resolve the Conversation binding for one Controller binding or Session id. * @param source - Session binding or identity. * @returns stable Conversation binding. + * @throws if the Session is unknown or its binding is no longer current. */ binding(source: SessionBinding | SessionId): ConversationBinding { - return this.resolveBinding(source) - } - - private resolveBinding(source: SessionBinding | SessionId): BoundConversation { const sessionId = typeof source === 'string' ? source : source.sessionId const owner = typeof source === 'string' ? this.sessions.binding(source) : source if (owner === undefined) throw new Error(`uiConversation.binding: unknown session "${sessionId}"`) diff --git a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts index a0f4c8ae95..603e32b3f5 100644 --- a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts @@ -16,7 +16,6 @@ import { import type { ConversationNodeDefinition, ConversationViewDefinition, ConversationViewNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { conversationOpenTurn } from '../src/client/conversation/assembly.ts' const SESSION_ID = 'resident' as SessionId @@ -148,7 +147,7 @@ describe('Conversation registries', () => { const { uiConversation, binding } = await bootRegistries() const conversation = uiConversation.binding(binding) const source = binding.eventSource as MutableSessionEventSource - const openTurn = conversationOpenTurn(uiConversation, binding) + const openTurn = conversation.openTurn const published: (number | undefined)[] = [] const listener = vi.fn(() => { published.push(openTurn.getSnapshot()) }) const unsubscribe = openTurn.subscribe(listener) @@ -171,7 +170,7 @@ describe('Conversation registries', () => { } }) expect(openTurn.getSnapshot()).toBe(2) expect(published).toEqual([1, undefined, 2]) - expect(conversationOpenTurn(uiConversation, binding)).toBe(openTurn) + expect(uiConversation.binding(binding).openTurn).toBe(openTurn) expect(conversation.snapshot.getSnapshot().activeTargets.size).toBe(0) unsubscribe() listener.mockClear() @@ -197,7 +196,7 @@ describe('Conversation registries', () => { source.replace([{ type: 'event', event: { type: 'step/start', seq: SessionSeq(2), time: 2, data: { turn: 7, step: 1 }, } }], true) - const openTurn = conversationOpenTurn(uiConversation, binding) + const openTurn = uiConversation.binding(binding).openTurn expect(openTurn.getSnapshot()).toBeUndefined() source.prepend([{ type: 'event', event: { type: 'turn/start', seq: SessionSeq(1), time: 1, data: { turn: 7 }, diff --git a/packages/client/ui-conversation/tests/stop-shortcut.client.spec.ts b/packages/client/ui-conversation/tests/stop-shortcut.client.spec.ts index 68b4b2ec2c..990c543da5 100644 --- a/packages/client/ui-conversation/tests/stop-shortcut.client.spec.ts +++ b/packages/client/ui-conversation/tests/stop-shortcut.client.spec.ts @@ -5,7 +5,7 @@ import type { Shortcuts, ShortcutContext, ShortcutGesture } from '@deepseek-ai/d import { makeTranslate, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' import { MutableSessionEventSource } from '@deepseek-ai/dsh-api-session-controller/client' import { SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types' -import { conversationOpenTurn, UiConversation } from '../src/client/conversation/assembly.ts' +import { UiConversation } from '../src/client/conversation/assembly.ts' import { ComposerBlockRegistry } from '../src/client/input/blocks.ts' import { InputHub } from '../src/client/input/hub.ts' import { ConversationController } from '../src/client/service.ts' @@ -47,7 +47,7 @@ async function bench() { void scoped.cancel() } const dispose = installStopShortcut(shortcuts, runtime.sessions, - binding => conversationOpenTurn(conversation, binding), runtime.ctx.uiSession, stop) + binding => conversation.binding(binding).openTurn, runtime.ctx.uiSession, stop) disposers.push(dispose) const root = document.createElement('div') root.dataset.conversationSession = 's1' diff --git a/packages/client/ui-dockkit/README.i18n.yaml b/packages/client/ui-dockkit/README.i18n.yaml index f598297d75..2e48f3244d 100644 --- a/packages/client/ui-dockkit/README.i18n.yaml +++ b/packages/client/ui-dockkit/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-dockkit/README.md -README.md: deae0abb23d7136d908a15351a8298e6b9a2ce43 -README.zh.md: 9dcbf6925bd0b5f8d22a0f729d46baa84211f41d +README.md: cd4433e3f7635ef3164058153f4377a8143ad0e9 +README.zh.md: 8a7fccea23ba6675e4d1bb066a4b461fb3a30e37 diff --git a/packages/client/ui-dockkit/README.md b/packages/client/ui-dockkit/README.md index deae0abb23..cd4433e3f7 100644 --- a/packages/client/ui-dockkit/README.md +++ b/packages/client/ui-dockkit/README.md @@ -39,7 +39,7 @@ A docking layout kit: a split tree of tabbed panes with invertible operations, a **The components** render a layout snapshot and report settled intents — one per gesture, never a drag frame. A drag previews in local state while the gesture's own facts stay in its closure; on release the net result leaves through one `DockIntents` call — a strip release reports the caret slot as drawn, the dragged chip counted, and `planPlaceTab` turns that into the reorder or the move. That is what lets an embedder record exactly one history entry per gesture. The strip follows the WAI-ARIA tabs pattern with manual activation: the selected chip is in the tab order; Left and Right (wrapping), Home, and End move focus between chips without selecting; Enter or Space selects the focused chip through the same intent as a click. A chip is a capsule carrying one control, its close; the context menu (a secondary press on the chip) carries the same close plus the embedder's items — a menu that would hold no item at all never shows — and renders in a portal positioned against the chip because the chip box clips its overflow on purpose (see below). After the chips sits the add control, which asks the embedder (`DockIntents.addTab`) to seat its seeded tab; the embedder's `canAddTab(paneId)` decides per pane whether the control is drawn at all. Copying a tab has no kit control — it is the embedder's API — and floating is the drag released clear of the surface. -Tab context menus show the close action name without a shortcut hint: they act on the clicked tab, while shortcuts can target another focused pane. An unmodified Escape dismisses the foreground tab menu without closing the tab and returns focus from a menu item to its tab. Composition and held-key repeats do not dismiss it. +Tab context menus show the close action name without a shortcut hint: they act on the clicked tab, while shortcuts can target another focused pane. An unmodified Escape dismisses the foreground tab menu without closing the tab and returns focus from a menu item to its tab without drawing a focus outline. Tab and arrow navigation retain the tab’s visible focus indicator. Composition and held-key repeats do not dismiss it. ## Embedding it diff --git a/packages/client/ui-dockkit/README.zh.md b/packages/client/ui-dockkit/README.zh.md index 9dcbf6925b..8a7fccea23 100644 --- a/packages/client/ui-dockkit/README.zh.md +++ b/packages/client/ui-dockkit/README.zh.md @@ -39,7 +39,7 @@ kind: "package-reference" **组件**渲染布局快照并上报已落定的意图——每次手势一条,绝不上报拖动帧。拖动过程中在本地状态里预览,手势自身的事实留在它的闭包里;松手时净结果通过一次 `DockIntents` 调用离开——在标签条上松手上报的是按绘制顺序数出的插入槽位(被拖的 chip 也计入),由 `planPlaceTab` 换算成重排或移动。正是这一点让嵌入方能为每次手势记录恰好一条历史。标签条遵循 WAI-ARIA tabs 模式的手动激活:选中的 chip 在 Tab 键序里;左右方向键(循环)、Home、End 只在 chip 之间移动焦点而不选中;Enter 或空格选中当前聚焦的 chip,走与点击相同的意图。chip 是一个胶囊,携带唯一的控件——它的关闭按钮;上下文菜单(在 chip 上的次键按下)携带同样的关闭项加上嵌入方的条目——一个连一项都没有的菜单绝不展示——并渲染在按 chip 定位的 portal 里,因为 chip 盒会故意裁掉溢出(见下文)。chip 之后是添加控件,它请嵌入方(`DockIntents.addTab`)安放其种子 tab;嵌入方的 `canAddTab(paneId)` 按格决定是否绘制该控件。复制 tab 没有套件控件——那是嵌入方的 API——而浮出就是把拖动松手在停靠区之外。 -标签上下文菜单只显示关闭操作名,不显示快捷键:菜单操作针对被点击的标签,快捷键则可能作用于其他焦点分栏。无修饰键的 Esc 关闭前台标签菜单而不关闭标签,并将菜单项的焦点交还标签。输入法组合输入和长按重复事件不关闭菜单。 +标签上下文菜单只显示关闭操作名,不显示快捷键:菜单操作针对被点击的标签,快捷键则可能作用于其他焦点分栏。无修饰键的 Esc 关闭前台标签菜单而不关闭标签,并将菜单项的焦点交还标签,不绘制焦点外轮廓线。Tab 和方向键导航保留标签页的可见焦点提示。输入法组合输入和长按重复事件不关闭菜单。 ## 如何嵌入 diff --git a/packages/client/ui-dockkit/src/components/TabMenu.tsx b/packages/client/ui-dockkit/src/components/TabMenu.tsx index d21937ad2c..b43da836fe 100644 --- a/packages/client/ui-dockkit/src/components/TabMenu.tsx +++ b/packages/client/ui-dockkit/src/components/TabMenu.tsx @@ -17,7 +17,7 @@ import { Children, useEffect, useLayoutEffect, useRef, useState } from 'react' import type { CSSProperties, ReactNode } from 'react' import { createPortal } from 'react-dom' -import { observeComposition } from '@deepseek-ai/dsh-client-ui-primitives' +import { focusWithoutRing, modalSelector, observeComposition } from '@deepseek-ai/dsh-client-ui-primitives' import type { DockLabels } from '../contract/adapter.ts' import css from './dockkit.module.css' @@ -70,13 +70,13 @@ export function TabMenu({ labels, anchor, onClose, onDismiss, extras }: TabMenuP const onKeyDown = (event: KeyboardEvent): void => { if (composition.guards(event) || event.defaultPrevented || event.key !== 'Escape' || event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) return - const top = [...document.querySelectorAll('[role="dialog"][aria-modal="true"], [role="menu"]')].at(-1) + const top = [...document.querySelectorAll(modalSelector)].at(-1) if (top !== menu) return event.preventDefault() if (event.repeat) return const restoreFocus = menu.contains(document.activeElement) onDismiss() - if (restoreFocus) anchor.focus() + if (restoreFocus) focusWithoutRing(anchor) } document.addEventListener('keydown', onKeyDown, true) // A press anywhere but inside the menu dismisses it; one with no element diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 2c7bc7fd59..1789108a42 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 9c73af31107c079868d5df2e8cb1d93e25562c82 -README.zh.md: 48115b391961a6e34ea776a4cad3fb49c90f36d6 +README.md: bc8a2fa80db873df6bd786f22e4cf8d2f6c877fb +README.zh.md: 15b1d600633c58fc9824a09cfcde99cd2b98718f diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 9c73af3110..bc8a2fa80d 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -25,9 +25,9 @@ Use `dsh-client-ui-primitives` to build web-client controls and render agent out ## Use this package -`Toast` uses the theme’s toast background and label colors in both light and dark modes. `Menu` data rows and `MenuItemButton` component rows accept owner-provided effective shortcuts and align their keys at the trailing edge as muted text without a background, including submenus. `ShortcutKeys` renders caller-supplied effective keys together in one rounded badge; its optional `className` lets the owner style interaction states. `Modal` and the settings shell share top-layer Escape and Tab handling through `useModalLayer`, restoring the previous focus on close. Components use `isBehindModal` to avoid moving focus behind a nested dialog. Menus consume their local Escape before modal dismissal. `observeComposition` supplies the same composition-end and legacy IME guard to local modal and recording handlers; callers dispose its document listeners with their interaction lifetime. +`Toast` uses the theme’s toast background and label colors in both light and dark modes. `Menu` data rows and `MenuItemButton` component rows accept owner-provided effective shortcuts and align their keys at the trailing edge as muted text without a background, including submenus. `ShortcutKeys` renders caller-supplied effective keys together in one rounded badge; its optional `className` lets the owner style interaction states. `Modal` and the settings shell share top-layer Escape and Tab handling through `useModalLayer`, restoring the previous focus on close. `closeTopModal(document)` requests the foreground modal’s current close action; newer menus or unregistered dialogs block dismissal of the modal behind them. Components use `isBehindModal` to avoid moving focus behind a nested dialog. Menus consume their local Escape before modal dismissal. `observeComposition` supplies the same composition-end and legacy IME guard to local modal and recording handlers; callers dispose its document listeners with their interaction lifetime. -Mark a dialog's initial control with `data-modal-autofocus` so the modal captures its invoking control before moving focus. Controls mounted with the dialog must not use React `autoFocus`, which runs before that capture. Tab and Shift+Tab from the dialog container enter its first and last focusable controls. +Automatic modal entry and modal/menu return focus, including after Escape and application close shortcuts, use `focusWithoutRing(element, options?)` to suppress the outline until Tab or directional navigation resumes normal focus styling. Dialog containers keep their outline-free styling. Existing borders, shadows, and error states remain intact. Mark a dialog's initial control with `data-modal-autofocus` so the modal captures its invoking control before moving focus. Controls mounted with the dialog must not use React `autoFocus`, which runs before that capture. Tab and Shift+Tab from the dialog container enter its first and last focusable controls. This package is a Web-shell build input. Its static ESM retains third-party imports and styles for Vite; independent consumers supply its development dependencies ([dependency rules](../AGENTS.md#dependency-declaration)). diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 48115b3919..15b1d60063 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -25,9 +25,9 @@ kind: "package-library" ## 使用本包 -`Toast` 在浅色和深色模式下均使用主题的系统提示背景与文字颜色。`Menu` 数据条目与 `MenuItemButton` 组件条目接收功能 owner 提供的有效快捷键,并在末端以无背景的浅灰色文字对齐显示,子菜单也采用同一呈现。`ShortcutKeys` 将调用方提供的有效按键组合显示在同一个圆角标签中;可选的 `className` 供调用方设置交互状态样式。`Modal` 与设置外壳通过 `useModalLayer` 共用顶层 Esc 和 Tab 处理,关闭时恢复先前焦点。组件通过 `isBehindModal` 避免将焦点移到嵌套弹窗后方。菜单先消费自己的 Esc,再由模态层处理关闭。 `observeComposition` 为局部弹层和录键处理提供相同的 composition-end 与旧版 IME 保护;调用方随交互生命周期释放其 document 监听。 +`Toast` 在浅色和深色模式下均使用主题的系统提示背景与文字颜色。`Menu` 数据条目与 `MenuItemButton` 组件条目接收功能 owner 提供的有效快捷键,并在末端以无背景的浅灰色文字对齐显示,子菜单也采用同一呈现。`ShortcutKeys` 将调用方提供的有效按键组合显示在同一个圆角标签中;可选的 `className` 供调用方设置交互状态样式。`Modal` 与设置外壳通过 `useModalLayer` 共用顶层 Esc 和 Tab 处理,关闭时恢复先前焦点。`closeTopModal(document)` 请求前台弹窗当前的关闭操作;上层菜单或未注册的对话框会阻止关闭其后方弹窗。组件通过 `isBehindModal` 避免将焦点移到嵌套弹窗后方。菜单先消费自己的 Esc,再由模态层处理关闭。 `observeComposition` 为局部弹层和录键处理提供相同的 composition-end 与旧版 IME 保护;调用方随交互生命周期释放其 document 监听。 -用 `data-modal-autofocus` 标记弹窗的初始控件,让模态层先保存触发控件,再移动焦点。随弹窗挂载的控件不得使用 React `autoFocus`,因为它会在保存触发控件前执行。弹窗容器获得焦点时,Tab 和 Shift+Tab 分别进入第一个和最后一个可聚焦控件。 +弹窗自动进入及弹窗、菜单回焦,包括通过 Esc 和应用关闭快捷键触发的回焦,均使用 `focusWithoutRing(element, options?)`,在 Tab 或方向键导航恢复正常焦点样式前抑制外轮廓线。弹窗容器仍不绘制焦点外框。原有边框、阴影和错误状态保持不变。用 `data-modal-autofocus` 标记弹窗的初始控件,让模态层先保存触发控件,再移动焦点。随弹窗挂载的控件不得使用 React `autoFocus`,因为它会在保存触发控件前执行。弹窗容器获得焦点时,Tab 和 Shift+Tab 分别进入第一个和最后一个可聚焦控件。 本包是 Web 壳的构建输入。静态 ESM 为 Vite 保留第三方导入和样式;独立消费方自行提供开发依赖([依赖规则](../AGENTS.md#dependency-declaration))。 diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index bd3efb0d45..fd7bffb1bf 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -7,6 +7,7 @@ import { overlayTopMargin } from './overlay-top-margin.ts' import { usePointerGrace } from './pointer-grace.ts' import { isBehindModal } from './useModalLayer.ts' import { observeComposition } from './keyboard-composition.ts' +import { focusWithoutRing } from './focus.ts' import { ShortcutKeys } from './ShortcutKeys.tsx' import css from './Menu.module.css' @@ -195,20 +196,22 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI * able to name by position. */ const triggerRef = useRef(null) + const selectingWithTab = useRef(false) /** * Hand the keyboard back to the trigger that opened the menu — or, when the * anchor never held it, to the anchor's first button. Focus left on a removed * row otherwise falls to the page body, where the next Tab restarts from the * top of the page. + * @param navigation - whether explicit keyboard traversal should retain its focus indicator. */ - const refocusAnchor = (): void => { + const refocusAnchor = (navigation = false): void => { const trigger = triggerRef.current - if (trigger !== null && document.contains(trigger) && !(trigger as HTMLButtonElement).disabled) { - trigger.focus() - return - } - rootRef.current?.querySelector('button:not(:disabled)')?.focus() + const target = trigger !== null && document.contains(trigger) && !(trigger as HTMLButtonElement).disabled + ? trigger : rootRef.current?.querySelector('button:not(:disabled)') + if (target == null) return + if (navigation) target.focus() + else focusWithoutRing(target) } /** @@ -219,10 +222,11 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI * its removal produced) comes back to the trigger. */ const refocusAfterSelection = (): void => { + const navigation = selectingWithTab.current queueMicrotask(() => { if (openRef.current) return const active = document.activeElement - if (active === null || active === document.body || listRef.current?.contains(active) === true) refocusAnchor() + if (active === null || active === document.body || listRef.current?.contains(active) === true) refocusAnchor(navigation) }) } const openRef = useRef(open) @@ -308,7 +312,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI if (!open || !autoFocus) return const first = listRef.current?.querySelector('button:not(:disabled)') walkIndex.current = first === undefined || first === null ? null : 0 - first?.focus() + if (first != null) focusWithoutRing(first) }, [open, autoFocus]) useEffect(() => { @@ -351,7 +355,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI if (e.shiftKey) { e.preventDefault() onClose() - refocusAnchor() + refocusAnchor(true) return } // Tab settles the row it is on; from anywhere else in the menu region @@ -361,7 +365,9 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI if (insideList) { if (focused instanceof Element && focused.getAttribute('role') === 'menuitem') { e.preventDefault() - ;(focused as HTMLElement).click() + selectingWithTab.current = true + try { (focused as HTMLElement).click() } + finally { selectingWithTab.current = false } } return } diff --git a/packages/client/ui-primitives/src/Modal.module.css b/packages/client/ui-primitives/src/Modal.module.css index 977bc6372c..8fa93ed1db 100644 --- a/packages/client/ui-primitives/src/Modal.module.css +++ b/packages/client/ui-primitives/src/Modal.module.css @@ -39,6 +39,10 @@ box-shadow: var(--dsw-elevation-prominent); } +.dialog:focus { + outline: none; +} + .content { display: flex; flex-direction: column; diff --git a/packages/client/ui-primitives/src/Modal.tsx b/packages/client/ui-primitives/src/Modal.tsx index 78d5a01247..0b40b9a489 100644 --- a/packages/client/ui-primitives/src/Modal.tsx +++ b/packages/client/ui-primitives/src/Modal.tsx @@ -26,7 +26,7 @@ type ModalProps = ModalBaseProps & ( /** * Render a centered, body-portaled modal over a blurred page mask. * @param props.open - whether the dialog is showing. - * @param props.onClose - Escape or mask click; while a menu is open inside the + * @param props.onClose - application close command, Escape, or mask click; while a menu is open inside the * dialog, Escape belongs to that menu first. * @param props.title - dialog heading (aria-label in every mode). * @param props.closeLabel - localized accessible close-button label. diff --git a/packages/client/ui-primitives/src/focus.ts b/packages/client/ui-primitives/src/focus.ts new file mode 100644 index 0000000000..bc7e5a372a --- /dev/null +++ b/packages/client/ui-primitives/src/focus.ts @@ -0,0 +1,29 @@ +/** Focus presentation for automatic entry and restoration. */ +const releases = new WeakMap void>() +const navigationKeys = new Set(['Tab', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End']) + +/** + * Focus an automatic destination without a focus outline until keyboard navigation or blur. + * The theme suppresses outlines while data-dsh-automatic-focus is present; borders and shadows remain intact. + * Tab and directional navigation restore normal focus styling. + * @param element - control or container receiving automatic focus. + * @param options - browser focus options, including scroll preservation. + */ +export function focusWithoutRing(element: HTMLElement, options?: FocusOptions): void { + releases.get(element)?.() + const release = (): void => { + element.removeAttribute('data-dsh-automatic-focus') + element.removeEventListener('blur', release) + element.removeEventListener('keydown', navigate, true) + releases.delete(element) + } + const navigate = (event: KeyboardEvent): void => { + if (!event.isComposing && !event.ctrlKey && !event.altKey && !event.metaKey && navigationKeys.has(event.key)) release() + } + releases.set(element, release) + element.setAttribute('data-dsh-automatic-focus', '') + element.addEventListener('blur', release) + element.addEventListener('keydown', navigate, true) + element.focus(options) + if (!element.matches(':focus')) release() +} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index f4c25b3771..c04bfc5355 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -31,7 +31,8 @@ export { useDismissOnOutsidePointer } from './useDismissOnOutsidePointer.ts' export { HoverCard } from './HoverCard.tsx' export { ShortcutKeys } from './ShortcutKeys.tsx' export { observeComposition } from './keyboard-composition.ts' -export { isBehindModal, useModalLayer } from './useModalLayer.ts' +export { focusWithoutRing } from './focus.ts' +export { closeTopModal, isBehindModal, modalSelector, useModalLayer } from './useModalLayer.ts' export { Modal } from './Modal.tsx' export { OnboardingSurface } from './OnboardingSurface.tsx' export { RiskConfirmation } from './RiskConfirmation.tsx' diff --git a/packages/client/ui-primitives/src/useModalLayer.ts b/packages/client/ui-primitives/src/useModalLayer.ts index bbe16002e6..dff4a22697 100644 --- a/packages/client/ui-primitives/src/useModalLayer.ts +++ b/packages/client/ui-primitives/src/useModalLayer.ts @@ -2,8 +2,24 @@ import { useLayoutEffect, useRef } from 'react' import type { RefObject } from 'react' import { observeComposition } from './keyboard-composition.ts' +import { focusWithoutRing } from './focus.ts' -const layers = new WeakMap() +/** Dialog and menu elements whose document order determines foreground shortcut ownership. */ +export const modalSelector = '[role="dialog"][aria-modal="true"], [role="menu"]' + +const layers = new WeakMap void }[]>() + +/** + * Request closure of the foreground registered modal using its current onClose callback. + * A newer menu or unregistered dialog blocks dismissal of the modal behind it. + * @param document - product document whose modal owns the close command. + */ +export function closeTopModal(document: Document): void { + const top = layers.get(document)?.at(-1) + if (top === undefined) return + const foreground = [...document.querySelectorAll(modalSelector)].at(-1) + if (foreground === top.element) top.close() +} /** * Whether an anchor belongs behind the current modal and must yield keyboard input. * @param anchor - local control owning the input handler. @@ -12,19 +28,20 @@ const layers = new WeakMap() export function isBehindModal(anchor: HTMLElement | null): boolean { if (anchor === null) return false const top = layers.get(anchor.ownerDocument)?.at(-1) - return top !== undefined && !top.contains(anchor) + return top !== undefined && !top.element.contains(anchor) } const focusable = 'button:not(:disabled), input:not(:disabled), textarea:not(:disabled), select:not(:disabled), a[href], [tabindex="0"]' /** * Give only the top modal Escape and Tab ownership, then restore its previous focus. + * Automatic entry and return focus omit outlines; keyboard traversal retains its indicators. * Controls mounted with the dialog use data-modal-autofocus for initial focus; * React autoFocus runs before this layer can capture the invoking control. * Local menus handle their Escape during capture before this bubble listener. * @param dialog - mounted dialog element. * @param open - whether this layer is active. - * @param onClose - top-layer Escape action. + * @param onClose - top-layer Escape or application close action. */ export function useModalLayer(dialog: RefObject, open: boolean, onClose: () => void): void { const close = useRef(onClose) @@ -37,13 +54,14 @@ export function useModalLayer(dialog: RefObject, open: boole const previous = document.activeElement const stack = layers.get(document) ?? [] layers.set(document, stack) - stack.push(element) + const layer = { element, close: () => { close.current() } } + stack.push(layer) const initial = element.querySelector('[data-modal-autofocus]') ?? element.querySelector(focusable) ?? element - if (!element.contains(document.activeElement)) initial.focus() + if (!element.contains(document.activeElement)) focusWithoutRing(initial) const keydown = (event: KeyboardEvent): void => { const composing = composition.guards(event) - if (stack.at(-1) !== element || event.defaultPrevented || composing + if (stack.at(-1) !== layer || event.defaultPrevented || composing || event.ctrlKey || event.altKey || event.metaKey) return if (event.key === 'Escape' && !event.shiftKey) { event.preventDefault() @@ -66,13 +84,13 @@ export function useModalLayer(dialog: RefObject, open: boole document.addEventListener('keydown', keydown) return () => { composition.dispose() - const wasTop = stack.at(-1) === element - stack.splice(stack.indexOf(element), 1) + const wasTop = stack.at(-1) === layer + stack.splice(stack.indexOf(layer), 1) document.removeEventListener('keydown', keydown) if (stack.length === 0) layers.delete(document) if (wasTop) { - if (previous instanceof HTMLElement && previous.isConnected) previous.focus() - else stack.at(-1)?.focus() + const target = previous instanceof HTMLElement && previous.isConnected ? previous : stack.at(-1)?.element + if (target !== undefined) focusWithoutRing(target) } } }, [dialog, open]) diff --git a/packages/client/ui-primitives/tests/atoms.client.spec.tsx b/packages/client/ui-primitives/tests/atoms.client.spec.tsx index 5e41a94b15..0727cb3743 100644 --- a/packages/client/ui-primitives/tests/atoms.client.spec.tsx +++ b/packages/client/ui-primitives/tests/atoms.client.spec.tsx @@ -223,7 +223,9 @@ describe('Menu', () => { // autoFocus parks the keyboard on the first row; Tab settles it like Enter. const alpha = screen.getByRole('menuitem', { name: 'Alpha' }) expect(document.activeElement).toBe(alpha) + expect(alpha.getAttribute('data-dsh-automatic-focus')).toBe('') expect(fireEvent.keyDown(alpha, { key: 'Tab' })).toBe(false) + expect(alpha.getAttribute('data-dsh-automatic-focus')).toBeNull() expect(onSelect).toHaveBeenCalledExactlyOnceWith('a') // Shift+Tab leaves like Escape: closed, with the trigger taking the keyboard. diff --git a/packages/client/ui-primitives/tests/modal-layer.client.spec.tsx b/packages/client/ui-primitives/tests/modal-layer.client.spec.tsx index 05d135c63e..253ccb7d48 100644 --- a/packages/client/ui-primitives/tests/modal-layer.client.spec.tsx +++ b/packages/client/ui-primitives/tests/modal-layer.client.spec.tsx @@ -1,9 +1,9 @@ // @vitest-environment jsdom import { useState } from 'react' -import { afterEach, expect, it } from 'vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { Modal } from '../src/Modal.tsx' -import { isBehindModal } from '../src/useModalLayer.ts' +import { closeTopModal, isBehindModal } from '../src/useModalLayer.ts' import { Menu } from '../src/Menu.tsx' afterEach(cleanup) @@ -24,6 +24,44 @@ function Nested({ withSearch = true }: { withSearch?: boolean }) { } const escape = (init = {}) => fireEvent.keyDown(document.activeElement ?? document, { key: 'Escape', code: 'Escape', ...init }) +it('closes only the top modal through application commands and restores each opener', () => { + render() + const settings = screen.getByRole('button', { name: 'Settings' }); settings.focus(); fireEvent.click(settings) + const reference = screen.getByRole('button', { name: 'Reference' }); reference.focus(); fireEvent.click(reference) + act(() => { closeTopModal(document) }) + expect(screen.queryByRole('dialog', { name: 'Reference' })).toBeNull() + expect(screen.getByRole('dialog', { name: 'Settings' })).toBeTruthy() + expect(document.activeElement).toBe(reference) + act(() => { closeTopModal(document) }) + expect(screen.queryByRole('dialog')).toBeNull() + expect(document.activeElement).toBe(settings) + closeTopModal(document) + expect(document.activeElement).toBe(settings) +}) + +it.each(['menu', 'dialog'])('does not dismiss a modal behind a newer %s', (role) => { + const onClose = vi.fn() + render() + const overlay = document.createElement('div') + overlay.setAttribute('role', role) + overlay.setAttribute('aria-modal', 'true') + document.body.append(overlay) + try { closeTopModal(document); expect(onClose).not.toHaveBeenCalled() } + finally { overlay.remove() } + closeTopModal(document) + expect(onClose).toHaveBeenCalledTimes(1) +}) + +it('uses the latest close callback and retains a modal whose owner declines dismissal', () => { + const first = vi.fn(), busy = vi.fn() + const view = render() + view.rerender() + closeTopModal(document) + expect(first).not.toHaveBeenCalled() + expect(busy).toHaveBeenCalledTimes(1) + expect(screen.getByRole('dialog')).toBeTruthy() +}) + it('closes one layer per Escape, honors local menus and IME, and restores the real opener', () => { expect(isBehindModal(null)).toBe(false) render() diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index d075e01007..29747d3f67 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-general/README.md -README.md: a929dcc90f5803d60a2bf21604912ecf00b23072 -README.zh.md: 541c97410c16dbfadecfcdbcb3b8a370bde56a23 +README.md: 91acedd187b7393addd0551059f7ae0e16a3cb4c +README.zh.md: 9f3f26153027180d5df3863ae14ea64f90aee156 diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index a929dcc90f..91acedd187 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -33,7 +33,7 @@ When the section navigation exceeds the panel's available height, the list scrol In Desktop, the account-row update control shows availability, progress, verification, readiness, and persistent retry feedback. It shares the connection indicator’s 28px height, 13px corners, 14px icon slot, 4px icon gap, and 12px medium text with an 18px line height; update-state border, fill, and text colors remain independently defined. Retry text and its dot use the same brand blue as other update labels; numeric download progress has no ellipsis. The preload carries semantic phase, version, progress, and classified failures; the component resolves every visible and accessible string from the active `settings` locale, including after an in-application language change. Selecting an available update starts downloading; installation requires a separate shell-owned confirmation. A collapsed sidebar shows the same status as a brand-blue dot on its top expand button, including failures. Connection feedback takes priority except during shell-reported installation, when the expected backend disconnect must not hide update status. Failure restores connection feedback. Both controls share one carrier subscription; browser code cannot choose packages or authorize installation. [Desktop updates](../../../apps/desktop/README.md) owns the release workflow. -Settings visibility and section selection live in the shell owner store. The shell supplies the effective Settings binding to the contributed launcher for menu keycaps and `aria-keyshortcuts`; the fallback button uses the same binding for hover and keyboard-focus hints and `aria-keyshortcuts`. The Settings command (`Mod+,` by default in Desktop) and the sidebar control open the same dialog. Repeating either action keeps the dialog open. Settings and the shortcut reference use body portals at the same modal layer. The last opened dialog appears above the other and receives keyboard focus; `Escape` closes the top dialog and restores focus to its invoking control. +Settings visibility and section selection live in the shell owner store. The shell supplies the effective Settings binding to the contributed launcher for menu keycaps and `aria-keyshortcuts`; the fallback button uses the same binding for hover and keyboard-focus hints and `aria-keyshortcuts`. The Settings command (`Mod+,` by default) toggles the dialog when Settings is in front or no modal is open; the sidebar control opens the same dialog. The command cannot open or close Settings while the shortcut reference or another modal is in front. Held-key repeats do nothing. Initial focus lands on the selected section in the navigation, or on the title when no sections are available, without drawing a focus outline. Tab and directional navigation retain their visible focus indicators. `Mod+/` can open the shortcut reference above Settings; `Escape` closes the top dialog and restores focus to its invoking control. Return focus omits outlines after shortcut, Escape, or pointer dismissal. ### The General section diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 541c97410c..9f3f261530 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -33,7 +33,7 @@ kind: "package-reference" 在 Desktop 中,账户行更新控件显示可用版本、进度、验证、就绪和持久重试反馈。它与连接指示器共用 28px 高度、13px 圆角、14px 图标占位、4px 图文间距,以及 12px 中等字重文字和 18px 行高;更新状态的边框、填充和文字颜色独立定义。重试文字及其圆点与其他更新文案使用相同的品牌蓝色;数字下载进度不带省略号。预加载仅传递语义化阶段、版本、进度和失败类别;组件从当前 `settings` locale 解析全部可见与无障碍文案,并在应用内语言变化后同步更新。选择可用更新即开始下载;安装需要独立的壳拥有的确认。侧栏收起时,其顶部展开按钮以品牌蓝色圆点显示相同状态,包括失败状态。连接反馈通常优先展示,但壳报告正在安装时,预期的后端断开不能遮盖更新状态;失败后恢复连接反馈。两个控件共用一次载体订阅;浏览器代码不能选择安装包或授权安装。[Desktop 更新](../../../apps/desktop/README.zh.md)负责发布流程。 -设置的可见状态和分区选择由 shell owner store 保存。外壳将设置命令的有效绑定传给入口贡献,用于菜单键帽和 `aria-keyshortcuts`;回退按钮使用相同绑定提供悬停、键盘聚焦提示和 `aria-keyshortcuts`。设置命令(Desktop 中默认为 `Mod+,`)与侧栏控件打开同一弹窗;重复执行设置命令或点击侧栏控件均保持弹窗打开。设置和快捷键速查通过 body portal 使用同一模态层级。最后打开的弹窗显示在另一弹窗上方并获得键盘焦点;`Escape` 关闭最上层弹窗,焦点返回触发该弹窗的控件。 +设置的可见状态和分区选择由 shell owner store 保存。外壳将设置命令的有效绑定传给入口贡献,用于菜单键帽和 `aria-keyshortcuts`;回退按钮使用相同绑定提供悬停、键盘聚焦提示和 `aria-keyshortcuts`。设置位于最前方或没有模态弹窗时,设置命令(默认为 `Mod+,`)切换设置弹窗的开关;侧栏控件打开同一弹窗。快捷键速查或其他模态弹窗位于前方时,该命令不能打开或关闭设置。长按产生的重复按键不执行操作。初始焦点落在导航中选中的分区;没有可用分区时落在标题上,均不绘制焦点外轮廓线。Tab 和方向键导航保留可见焦点提示。`Mod+/` 可在设置上方打开快捷键速查;`Escape` 关闭最上层弹窗,焦点返回触发该弹窗的控件。通过快捷键、Esc 或鼠标关闭后的回焦均不绘制外轮廓线。 ### 「通用」分区 diff --git a/packages/client/ui-settings-general/src/client/SettingsRoot.module.css b/packages/client/ui-settings-general/src/client/SettingsRoot.module.css index 2aff6dc924..284c95b2f2 100644 --- a/packages/client/ui-settings-general/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings-general/src/client/SettingsRoot.module.css @@ -102,6 +102,10 @@ --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); } +.panel:focus { + outline: none; +} + /* Nav rail (figma .Setting-nav 501:29958): 188 wide, pad (12,22,12,0), gap 18, no own fill — the panel white shows through. */ .nav { diff --git a/packages/client/ui-settings-general/src/client/SettingsRoot.tsx b/packages/client/ui-settings-general/src/client/SettingsRoot.tsx index 66c1a0ae5d..3e8539bbfb 100644 --- a/packages/client/ui-settings-general/src/client/SettingsRoot.tsx +++ b/packages/client/ui-settings-general/src/client/SettingsRoot.tsx @@ -65,7 +65,8 @@ function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelP