fix(shortcuts): finish modal toggles and automatic focus handling
This commit is contained in:
1 parent
120cac4ea7
commit
f6006c5547
56 files changed
+603
-163
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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'] },
|
||||
|
||||
@@ -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+,`)
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<ShortcutRegistry
|
||||
const reset = (): void => { 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 => {
|
||||
|
||||
@@ -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<HTMLElement>('[role="dialog"][aria-modal="true"], [role="menu"]')].at(-1)
|
||||
const top = [...window.document.querySelectorAll<HTMLElement>(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') }
|
||||
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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`。
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<ConversationSnapshot>
|
||||
/**
|
||||
* 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<number | undefined>
|
||||
/**
|
||||
* 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<number | undefined>
|
||||
|
||||
/**
|
||||
* 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<number | undefined> {
|
||||
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<SessionBinding, BindingRecord>()
|
||||
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}"`)
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
<a id="embedding-it"></a>
|
||||
## Embedding it
|
||||
|
||||
@@ -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 和方向键导航保留标签页的可见焦点提示。输入法组合输入和长按重复事件不关闭菜单。
|
||||
|
||||
<a id="embedding-it"></a>
|
||||
## 如何嵌入
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -25,9 +25,9 @@ Use `dsh-client-ui-primitives` to build web-client controls and render agent out
|
||||
<a id="use-this-package"></a>
|
||||
## 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)).
|
||||
|
||||
|
||||
@@ -25,9 +25,9 @@ kind: "package-library"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
`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))。
|
||||
|
||||
|
||||
@@ -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<HTMLElement | null>(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<HTMLButtonElement>('button:not(:disabled)')?.focus()
|
||||
const target = trigger !== null && document.contains(trigger) && !(trigger as HTMLButtonElement).disabled
|
||||
? trigger : rootRef.current?.querySelector<HTMLButtonElement>('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<HTMLButtonElement>('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
|
||||
}
|
||||
|
||||
@@ -39,6 +39,10 @@
|
||||
box-shadow: var(--dsw-elevation-prominent);
|
||||
}
|
||||
|
||||
.dialog:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/** Focus presentation for automatic entry and restoration. */
|
||||
const releases = new WeakMap<HTMLElement, () => 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()
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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<Document, HTMLElement[]>()
|
||||
/** 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<Document, { element: HTMLElement; close: () => 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<Document, HTMLElement[]>()
|
||||
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<HTMLElement | null>, open: boolean, onClose: () => void): void {
|
||||
const close = useRef(onClose)
|
||||
@@ -37,13 +54,14 @@ export function useModalLayer(dialog: RefObject<HTMLElement | null>, 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<HTMLElement>('[data-modal-autofocus]')
|
||||
?? element.querySelector<HTMLElement>(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<HTMLElement | null>, 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])
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(<Nested />)
|
||||
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(<Modal open title="Settings" closeLabel="Close" onClose={onClose} />)
|
||||
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(<Modal open title="Settings" closeLabel="Close" onClose={first} />)
|
||||
view.rerender(<Modal open title="Settings" closeLabel="Close" onClose={busy} />)
|
||||
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(<Nested />)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 或鼠标关闭后的回焦均不绘制外轮廓线。
|
||||
|
||||
### 「通用」分区
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -65,7 +65,8 @@ function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelP
|
||||
<div className={css.mask} aria-hidden="true" onClick={onClose} />
|
||||
<div ref={panel} tabIndex={-1} data-shortcut-modal="settings" className={css.panel} role="dialog" aria-modal="true" aria-labelledby={titleId}>
|
||||
<nav className={css.nav}>
|
||||
<div className={css.navTitle} id={titleId}>{renderSlot('settings.header', {})}</div>
|
||||
<div className={css.navTitle} id={titleId} tabIndex={-1}
|
||||
data-modal-autofocus={active === undefined ? '' : undefined}>{renderSlot('settings.header', {})}</div>
|
||||
<div className={css.navList}>
|
||||
{rows.map(row => (
|
||||
<button
|
||||
@@ -73,6 +74,7 @@ function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelP
|
||||
type="button"
|
||||
className={clsx(css.navCell, row.id === active && css.active)}
|
||||
aria-current={row.id === active ? 'true' : undefined}
|
||||
data-modal-autofocus={row.id === active ? '' : undefined}
|
||||
onClick={() => { onSelect(row.id) }}
|
||||
>
|
||||
{navIcon(row.id)}
|
||||
@@ -84,7 +86,7 @@ function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelP
|
||||
<div className={css.content}>
|
||||
<div className={css.header}>
|
||||
<div className={css.actions}>{renderSlot('settings.action', {})}</div>
|
||||
<button data-modal-autofocus type="button" className={css.close} onClick={onClose}>
|
||||
<button type="button" className={css.close} onClick={onClose}>
|
||||
<IconCloseOutlineRegular size={14} />
|
||||
<span className={css.hiddenLabel}>{renderSlot('settings.close', {})}</span>
|
||||
</button>
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { closeTopModal } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
// Type-only: the settings slot declarations plus the ctx.configForms Context
|
||||
// merge. Cross-plugin collaboration goes through the service, never a value
|
||||
// import (client bundle purity gate).
|
||||
@@ -185,7 +186,13 @@ export function apply(ctx: ClientContext): void {
|
||||
'web:windows': { code: 'Comma', modifiers: ['primary'] },
|
||||
},
|
||||
regions: ['page', 'editable', 'terminal'], modals: ['settings'],
|
||||
resolve: () => ({ status: 'handled', run: () => { shellInstance.actions.open() } }),
|
||||
resolve: ({ modal }) => {
|
||||
if (modal !== null && modal !== 'settings') return { status: 'blocked', reason: 'modal' }
|
||||
return { status: 'handled', run: () => {
|
||||
if (modal === 'settings') closeTopModal(document)
|
||||
else shellInstance.actions.open()
|
||||
} }
|
||||
},
|
||||
})
|
||||
|
||||
const disposeSlot = ctx.slots.register({
|
||||
|
||||
@@ -326,10 +326,10 @@ describe('SettingsPanel close paths', () => {
|
||||
expect(screen.getByRole('dialog')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('lands focus on the close button when the dialog opens', () => {
|
||||
it('lands focus on the active section when the dialog opens', () => {
|
||||
mount()
|
||||
openPanel()
|
||||
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'General' }))
|
||||
})
|
||||
|
||||
it('opens above an existing body modal and gives the visible settings panel keyboard ownership', () => {
|
||||
@@ -344,7 +344,7 @@ describe('SettingsPanel close paths', () => {
|
||||
const settings = screen.getByRole('dialog', { name: 'Settings Title' })
|
||||
expect(settings.parentElement?.parentElement).toBe(document.body)
|
||||
expect(reference.parentElement!.compareDocumentPosition(settings.parentElement!)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'General' }))
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(screen.queryByRole('dialog', { name: 'Settings Title' })).toBeNull()
|
||||
expect(closeReference).not.toHaveBeenCalled()
|
||||
@@ -412,6 +412,7 @@ describe('SettingsPanel navigation', () => {
|
||||
})
|
||||
expect(screen.getByRole('dialog')).toBeTruthy()
|
||||
expect(screen.getByTestId('section-models')).toBeTruthy()
|
||||
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Models' }))
|
||||
|
||||
cleanup()
|
||||
const inactive = mount({ onboardingActive: false }).renderSlot.mock.calls
|
||||
@@ -450,9 +451,10 @@ describe('SettingsPanel navigation', () => {
|
||||
expect(screen.getByTestId('section-general')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders an empty content column when the ledger is empty', () => {
|
||||
it('renders an empty content column and focuses the title when the ledger is empty', () => {
|
||||
const { renderSlot } = mount({ rows: [] })
|
||||
openPanel()
|
||||
expect(document.activeElement).toBe(screen.getByText('Settings Title'))
|
||||
expect(screen.getByRole('dialog')).toBeTruthy()
|
||||
const sectionCalls = renderSlot.mock.calls.filter(c => c[0] === 'settings.section')
|
||||
expect(sectionCalls).toHaveLength(0)
|
||||
|
||||
@@ -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-shortcuts/README.md
|
||||
README.md: 0e19b26d7ab5111afb5b7f7297dcb4fd43b6528b
|
||||
README.zh.md: 7307e53a6ea0a8d176121262e5261871885744bc
|
||||
README.md: a8bef7d9d41353eda7b86127d641a123534e4128
|
||||
README.zh.md: 77bcace81b28da5cc1ec66fb30e05ca9abb3c5ba
|
||||
@@ -25,7 +25,9 @@ Browse the commands available in the current window and find them by action, Eng
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
The Web app bundle mounts this package together with `shortcuts`. The reference shows only registered commands and existing fixed input actions. Desktop shows the effective device keycaps; commands without a Web default show No shortcut. The Settings entry and Mod+/ open the same single dialog, titled “Keyboard shortcuts” in English and “快捷键” in Chinese. Its 480×600 CSS-pixel card shrinks to fit smaller windows; the header and search remain above an internally scrolling list. The search field receives focus on open. The Settings entry uses a filled capsule button without a dropdown icon and reads the same effective binding for its tooltip and `aria-keyshortcuts`; both are absent without a binding.
|
||||
The Web app bundle mounts this package together with `shortcuts`. The reference shows only registered commands and existing fixed input actions. Desktop shows the effective device keycaps; commands without a Web default show No shortcut. The Settings entry and Mod+/ open the same single dialog, titled “Keyboard shortcuts” in English and “快捷键” in Chinese. Its 480×600 CSS-pixel card shrinks to fit smaller windows; the header and search remain above an internally scrolling list. The search field receives focus on open without changing its border color. Automatic entry and return focus in the reference, recorder, and reset confirmation omit outlines, including returns after Escape and application close shortcuts. Containers remain outline-free, and Tab and directional navigation retain their visible focus indicators. The Settings entry uses a filled capsule button without a dropdown icon and reads the same effective binding for its tooltip and `aria-keyshortcuts`; both are absent without a binding.
|
||||
|
||||
`Mod+/` opens the reference when no modal is open or Settings is in front, and closes it when the reference is in front. While the reference is in front, the Settings command cannot open or close Settings. Held-key repeats are ignored. Recording, pending writes, and a nested confirmation prevent the reference shortcut from closing the dialog.
|
||||
|
||||
Search matches an ordered subsequence within one label, English alias, or key combination, ignoring case. Each matching command appears once. Rows within each group follow command ID order, independent of plugin registration order; search relevance takes precedence and equal matches retain that order. Application and fixed-action rows remain 42 CSS pixels high during normal display and recording; long command names are truncated. Application rows show command names and binding controls without availability subtitles or reason tooltips, including conflicting and invalid bindings. Commands that cannot execute remain silent. Fixed actions show disabled, dimmed key buttons without a separate status label; they cannot enter recording and do not highlight on hover.
|
||||
|
||||
|
||||
@@ -25,7 +25,9 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用此包
|
||||
|
||||
Web 应用 bundle 将此包与 `shortcuts` 一同挂载。速查仅展示已注册命令和现有固定输入操作。桌面端展示设备对应的有效键帽;没有 Web 默认键位的命令显示“暂无快捷键”。设置入口与 Mod+/打开同一个单实例弹窗,中文标题为“快捷键”,英文标题为“Keyboard shortcuts”。卡片尺寸为 480×600 CSS 像素,小窗口中缩小以保持可见;标题和搜索框位于内部滚动列表上方。打开时聚焦搜索框。设置入口使用无下拉图标的填充胶囊按钮,并从同一有效绑定读取提示及 `aria-keyshortcuts`;没有绑定时不显示提示,也不设置该属性。
|
||||
Web 应用 bundle 将此包与 `shortcuts` 一同挂载。速查仅展示已注册命令和现有固定输入操作。桌面端展示设备对应的有效键帽;没有 Web 默认键位的命令显示“暂无快捷键”。设置入口与 Mod+/打开同一个单实例弹窗,中文标题为“快捷键”,英文标题为“Keyboard shortcuts”。卡片尺寸为 480×600 CSS 像素,小窗口中缩小以保持可见;标题和搜索框位于内部滚动列表上方。打开时聚焦搜索框,边框颜色保持不变。速查窗、录键框及重置确认框内的自动进入和回焦均不绘制外轮廓线,包括通过 Esc 和应用关闭快捷键触发的回焦。容器仍不绘制焦点外框,Tab 和方向键导航保留可见焦点提示。设置入口使用无下拉图标的填充胶囊按钮,并从同一有效绑定读取提示及 `aria-keyshortcuts`;没有绑定时不显示提示,也不设置该属性。
|
||||
|
||||
没有模态弹窗或设置位于最前方时,`Mod+/` 打开速查;速查位于最前方时,该快捷键关闭速查。速查位于前方时,设置命令不能打开或关闭设置。长按产生的重复按键不执行操作。录键期间、写入尚未完成或嵌套确认框打开时,速查快捷键不会关闭弹窗。
|
||||
|
||||
搜索在单个操作名称、英文别名或按键组合内按字符顺序模糊匹配,忽略大小写。每个命中命令只出现一次。各分组内按命令 ID 排序,不受插件注册顺序影响;搜索时优先按相关性排序,同分项保留该顺序。正常显示和录制时,应用操作行和固定操作行均保持 42 CSS 像素高;过长的命令名称会被截断。应用命令行只显示名称和改键控件,不显示可用性副标题或原因悬浮提示,冲突和无效绑定也不例外。无法执行的命令静默结束。固定操作使用灰色禁用的键位按钮,不额外显示状态文字;按钮不能进入录键,悬停时不高亮。
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Inline physical-key recording and revision-aware command editing. */
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { ShortcutKeys, observeComposition } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { ShortcutKeys, focusWithoutRing, observeComposition } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ShortcutBinding, ShortcutCommandId, ShortcutEdit, ShortcutRevision } from '@deepseek-ai/dsh-client-shortcuts/protocol'
|
||||
import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client'
|
||||
import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -152,7 +152,10 @@ EditorProps) {
|
||||
void recording(false).catch(() => { /* The owning window may already be closed. */ })
|
||||
}
|
||||
}, [describeBinding, recording, t, targetId, runtime, platform, desktopChords])
|
||||
useEffect(() => { if (nativeReady) recorder.current?.focus() }, [nativeReady])
|
||||
useEffect(() => {
|
||||
const element = recorder.current
|
||||
if (nativeReady && element !== null) focusWithoutRing(element)
|
||||
}, [nativeReady])
|
||||
const readonly = busy || stale || config.status !== 'ready'
|
||||
const review = stale && <div className={css.review}>
|
||||
<span>{t('stale')}</span><button type="button" className={css.inlineAction} disabled={busy}
|
||||
|
||||
@@ -109,7 +109,6 @@
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
}
|
||||
.searchField:focus-within { border-color: var(--dsw-alias-label-caption); }
|
||||
.searchIcon { flex: none; color: var(--dsw-alias-label-caption); }
|
||||
.search {
|
||||
width: 100%;
|
||||
@@ -196,7 +195,7 @@ p.hint { margin: 6px 0; }
|
||||
.inlineControls { display: flex; justify-content: flex-end; align-items: center; gap: 8px; }
|
||||
.inlineAction { padding: 0; border: 0; background: transparent; color: var(--dsw-alias-label-tertiary); font: inherit; font-size: 12px; line-height: 18px; white-space: nowrap; cursor: pointer; }
|
||||
.recorder { display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; min-width: 96px; height: 26px; padding: 3px 14px; border: 1px solid var(--dsw-alias-label-tertiary); border-radius: 10px; background: var(--dsw-alias-bg-module-platform); color: var(--dsw-alias-label-tertiary); font: inherit; font-size: 12px; line-height: 18px; white-space: nowrap; cursor: pointer; }
|
||||
.recorder:focus { outline: none; }
|
||||
.recorder:focus-visible { outline: 1px solid var(--dsw-alias-label-tertiary); outline-offset: 2px; }
|
||||
.recorder.invalid { border: 1px solid var(--dsw-alias-state-error-primary); box-shadow: none; }
|
||||
.recorded { height: auto; padding: 0; background: transparent; color: inherit; }
|
||||
.review { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 18px; }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Searchable editable shortcut reference and its General Settings row. */
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { Button, IconCloseOutlineRegular, IconRefreshOutlineRegular, Modal, ShortcutKeys, Tooltip, Toast, isBehindModal, rankByName } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { Button, IconCloseOutlineRegular, IconRefreshOutlineRegular, Modal, ShortcutKeys, Tooltip, Toast, focusWithoutRing, isBehindModal, rankByName } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ObservableSnapshot, PropsStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { ShortcutCatalogEntry, ShortcutPlatform, Shortcuts } from '@deepseek-ai/dsh-client-shortcuts/client'
|
||||
@@ -73,7 +73,9 @@ export function ShortcutReference({
|
||||
}, [open, config, runtime, notify, t])
|
||||
const closeEditor = (): void => {
|
||||
setTarget(null)
|
||||
search.current?.closest<HTMLElement>('[role="dialog"]')?.focus({ preventScroll: true })
|
||||
const dialog = search.current?.closest<HTMLElement>('[role="dialog"]')
|
||||
/* v8 ignore next -- Editor callbacks run while its reference dialog and search input are mounted. */
|
||||
if (dialog != null) focusWithoutRing(dialog, { preventScroll: true })
|
||||
}
|
||||
const closeReference = (): void => {
|
||||
if (busy) return
|
||||
@@ -107,7 +109,10 @@ export function ShortcutReference({
|
||||
onClose: closeEditor,
|
||||
onSaved: () => { notify(t('saved')); closeEditor() },
|
||||
onError: (message: string) => { notify(message, true) } }
|
||||
useLayoutEffect(() => { if (open && !isBehindModal(search.current)) search.current?.focus() }, [open, focusRequest])
|
||||
useLayoutEffect(() => {
|
||||
const input = search.current
|
||||
if (open && input !== null && !isBehindModal(input)) focusWithoutRing(input)
|
||||
}, [open, focusRequest])
|
||||
const entries = [
|
||||
...catalog.map(row => ({
|
||||
...row,
|
||||
@@ -120,7 +125,10 @@ export function ShortcutReference({
|
||||
const matches = [...new Set(ranked.map(match => match.row))]
|
||||
const modifiedCount = Object.keys(config.document.profiles[`${runtime}:${platform}`] ?? {}).length
|
||||
useLayoutEffect(() => {
|
||||
if (open && resetRevision === null && modifiedCount === 0 && document.activeElement === document.body) search.current?.focus()
|
||||
const input = search.current
|
||||
if (open && resetRevision === null && modifiedCount === 0 && document.activeElement === document.body && input !== null) {
|
||||
focusWithoutRing(input)
|
||||
}
|
||||
}, [open, resetRevision, modifiedCount])
|
||||
return <><Modal open={open} onClose={closeReference} title={t('title')} headless
|
||||
shortcutModal="shortcuts" className={css.dialog as string}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Shortcut reference plugin; commands and entry points share one declared store. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client'
|
||||
import { closeTopModal } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
@@ -54,7 +55,13 @@ export function apply(ctx: Context): void {
|
||||
'web:linux': { code: 'Slash', modifiers: ['primary'] },
|
||||
},
|
||||
regions: ['page', 'editable', 'terminal'], modals: ['settings', 'shortcuts'],
|
||||
resolve: () => ({ status: 'handled', run: () => { instance.actions.open() } }),
|
||||
resolve: ({ modal }) => {
|
||||
if (modal !== null && modal !== 'settings' && modal !== 'shortcuts') return { status: 'blocked', reason: 'modal' }
|
||||
return { status: 'handled', run: () => {
|
||||
if (modal === 'shortcuts') closeTopModal(document)
|
||||
else instance.actions.open()
|
||||
} }
|
||||
},
|
||||
})
|
||||
const disposeSlot = ctx.slots.register({
|
||||
name: 'shell.overlay', id: 'shortcuts', locale: 'shortcuts', store,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, onTestFinished, vi } from 'vitest'
|
||||
import { createElement, Fragment, useSyncExternalStore } from 'react'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
|
||||
import { Modal } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type {} from '@deepseek-ai/dsh-client-shortcuts/client'
|
||||
import type { DesktopKeyboardApi, DesktopShortcutInput } from '@deepseek-ai/dsh-client-shortcuts/protocol'
|
||||
import type { DesktopKeyboardApi, DesktopShortcutInput, ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/protocol'
|
||||
import type { createSettingsShellStore } from '../../ui-settings-general/src/client/shell-store.ts'
|
||||
import type { createLayoutStore } from '../../ui-layout/src/client/stores.ts'
|
||||
import type { ReferenceInjected } from '../src/client/Reference.tsx'
|
||||
@@ -46,7 +49,7 @@ describe('assembled shortcut command owners', () => {
|
||||
expect(shortcuts.fixedCatalog.getSnapshot().some(row => row.id.startsWith('fixed.'))).toBe(false)
|
||||
}, 60_000)
|
||||
|
||||
it('shares the settings/reference stores and gives desktop bindings priority over modals without repeating actions', async ({ start }) => {
|
||||
it('toggles foreground dialogs, permits shortcuts above settings, and blocks settings behind shortcuts', async ({ start }) => {
|
||||
const previous = document.documentElement.dataset.platform
|
||||
const previousBridge = Object.getOwnPropertyDescriptor(window, 'dshDesktop')
|
||||
const listeners = new Set<(input: DesktopShortcutInput) => void>()
|
||||
@@ -60,6 +63,7 @@ describe('assembled shortcut command owners', () => {
|
||||
modal.setAttribute('role', 'dialog')
|
||||
modal.setAttribute('aria-modal', 'true')
|
||||
onTestFinished(() => {
|
||||
cleanup()
|
||||
modal.remove()
|
||||
if (previousBridge === undefined) Reflect.deleteProperty(window, 'dshDesktop')
|
||||
else Object.defineProperty(window, 'dshDesktop', previousBridge)
|
||||
@@ -71,27 +75,80 @@ describe('assembled shortcut command owners', () => {
|
||||
const settings = (client.ctx.slots.entries('sidebar.settings')[0]!.store as ReturnType<typeof createSettingsShellStore>).create()
|
||||
const reference = (client.ctx.slots.entries('shell.overlay').find(entry => entry.options.id === 'shortcuts')!.store as ReturnType<typeof createShortcutsStore>).create()
|
||||
const layout = (client.ctx.slots.entries('root')[0]!.store as ReturnType<typeof createLayoutStore>).create()
|
||||
function Dialogs() {
|
||||
const settingsState = useSyncExternalStore(listener => settings.subscribe(listener), () => settings.getSnapshot())
|
||||
const referenceState = useSyncExternalStore(listener => reference.subscribe(listener), () => reference.getSnapshot())
|
||||
return createElement(Fragment, null,
|
||||
createElement(Modal, { open: settingsState.open, title: 'Settings', closeLabel: 'Close settings',
|
||||
shortcutModal: 'settings', onClose: settings.actions.close }),
|
||||
createElement(Modal, { open: referenceState.open, title: 'Shortcuts', closeLabel: 'Close shortcuts',
|
||||
shortcutModal: 'shortcuts', onClose: reference.actions.close }))
|
||||
}
|
||||
render(createElement(Dialogs))
|
||||
const press = (code: string, repeat = false): void => {
|
||||
for (const listener of listeners) listener({ kind: 'keyboard', revision: shortcuts.config.getSnapshot().revision,
|
||||
frameName: '', code, control: false, alt: false, shift: false, meta: true, repeat })
|
||||
act(() => {
|
||||
for (const listener of listeners) listener({ kind: 'keyboard', revision: shortcuts.config.getSnapshot().revision,
|
||||
frameName: '', code, control: false, alt: false, shift: false, meta: true, repeat })
|
||||
})
|
||||
}
|
||||
press('Comma')
|
||||
expect(settings.getSnapshot().open).toBe(true)
|
||||
const opened = settings.getSnapshot()
|
||||
modal.dataset.shortcutModal = 'settings'
|
||||
document.body.append(modal)
|
||||
press('Comma')
|
||||
press('Comma', true)
|
||||
expect(settings.getSnapshot()).toBe(opened)
|
||||
press('Comma')
|
||||
expect(settings.getSnapshot().open).toBe(false)
|
||||
press('Comma')
|
||||
act(() => { settings.actions.select('general') })
|
||||
const selected = settings.getSnapshot()
|
||||
press('Slash')
|
||||
expect(reference.getSnapshot().open).toBe(true)
|
||||
modal.dataset.shortcutModal = 'shortcuts'
|
||||
act(() => { reference.actions.search('session') })
|
||||
const searched = reference.getSnapshot()
|
||||
press('Slash', true)
|
||||
press('Comma')
|
||||
expect(settings.getSnapshot()).toBe(selected)
|
||||
expect(reference.getSnapshot()).toBe(searched)
|
||||
|
||||
document.body.append(modal)
|
||||
press('Comma')
|
||||
press('Slash')
|
||||
expect(settings.getSnapshot()).toBe(selected)
|
||||
expect(reference.getSnapshot()).toBe(searched)
|
||||
modal.remove()
|
||||
|
||||
press('Slash')
|
||||
expect(reference.getSnapshot()).toMatchObject({ open: false, query: '' })
|
||||
expect(settings.getSnapshot()).toBe(selected)
|
||||
press('Comma')
|
||||
expect(settings.getSnapshot()).toEqual({ open: false, activeId: undefined })
|
||||
press('Slash')
|
||||
expect(reference.getSnapshot().open).toBe(true)
|
||||
press('Comma')
|
||||
expect(settings.getSnapshot().open).toBe(false)
|
||||
const sidebar = layout.getSnapshot().layoutInfo.sidebar
|
||||
press('KeyB')
|
||||
expect(layout.getSnapshot().layoutInfo.sidebar).not.toBe(sidebar)
|
||||
modal.remove()
|
||||
press('Slash')
|
||||
expect(reference.getSnapshot().open).toBe(false)
|
||||
press('KeyB')
|
||||
expect(layout.getSnapshot().layoutInfo.sidebar).toBe(sidebar)
|
||||
press('KeyB', true)
|
||||
expect(layout.getSnapshot().layoutInfo.sidebar).toBe(sidebar)
|
||||
const menu = (commandId: ShortcutCommandId): void => {
|
||||
act(() => {
|
||||
for (const listener of listeners) listener({ kind: 'menu', commandId, revision: shortcuts.config.getSnapshot().revision })
|
||||
})
|
||||
}
|
||||
menu('settings.open' as ShortcutCommandId)
|
||||
expect(settings.getSnapshot().open).toBe(true)
|
||||
menu('settings.open' as ShortcutCommandId)
|
||||
expect(settings.getSnapshot().open).toBe(false)
|
||||
menu('shortcuts.open' as ShortcutCommandId)
|
||||
expect(reference.getSnapshot().open).toBe(true)
|
||||
menu('settings.open' as ShortcutCommandId)
|
||||
expect(settings.getSnapshot().open).toBe(false)
|
||||
menu('shortcuts.open' as ShortcutCommandId)
|
||||
expect(reference.getSnapshot().open).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -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-sidebar-right/README.md
|
||||
README.md: 7edbd5188f699b578db05441e835a18993ebe29c
|
||||
README.zh.md: 8b9b45f799866c1c17b78151890c2b7eb17d03ac
|
||||
README.md: 2f851c638e24a20bed7a5fd59fa17f78f2d64ae5
|
||||
README.zh.md: c45ba23db9e081a9d32edace52890f41033b755b
|
||||
@@ -107,7 +107,7 @@ A type's `keepMounted: true` retains visited bodies across tab and Session chang
|
||||
|
||||
Tab owners register `registerCloseHandler(kind, handler)` through an effect. A handler synchronously retains any background cleanup before allowing explicit close or replacement. The resource owner tracks completion and retry; the sidebar does not wait. A thrown handler preserves the tab. Collapse, presentation changes and plugin disposal do not invoke close handlers; the tab abort signal identifies occurrence disposal, not an explicit close.
|
||||
|
||||
Close and refresh commands resolve the focused pane and tab occurrence. Page bodies bind their refresh operation through `tab.actions.bindCommands()` and release it on unmount; ended occurrences cannot regain capabilities. Refresh never reloads the application. Shortcut page opens, sidebar expansion, and all splits focus the selected dock or floating pane after its DOM commits, including singleton reveals and guide replacements. Sidebar expansion focuses the active dock pane; collapse preserves focus inside floating panes. Pane focus preserves existing text selections. If a focused page input or iframe is replaced, focus returns to its visible pane only while no other element owns focus; an outside pointer gesture or explicit blur cancels this restoration. Close checks the captured identity again and uses resource cleanup. After a keyboard or native-menu close, focus moves to a surviving visible pane in the same Session so consecutive close commands continue there. Stale targets and cleanup failures never fall back to closing a window. Modal restrictions follow the [shortcut service](../shortcuts/README.md); Windows and macOS Desktop bindings take priority over modal controls. Closing the sole docked guide with a shortcut or native menu collapses the sidebar and retains the guide. Desktop requests window closure through the shortcut service only when no sidebar page owns focus; Web leaves the browser open.
|
||||
Close and refresh commands resolve the focused pane and tab occurrence. Page bodies bind their refresh operation through `tab.actions.bindCommands()` and release it on unmount; ended occurrences cannot regain capabilities. Refresh never reloads the application. Shortcut page opens, sidebar expansion, and all splits focus the selected dock or floating pane after its DOM commits, including singleton reveals and guide replacements. Sidebar expansion focuses the active dock pane; collapse preserves focus inside floating panes. Pane focus preserves existing text selections. If a focused page input or iframe is replaced, focus returns to its visible pane only while no other element owns focus; an outside pointer gesture or explicit blur cancels this restoration. Close checks the captured identity again and uses resource cleanup. After a keyboard or native-menu close, focus moves to a surviving visible pane in the same Session so consecutive close commands continue there. Stale targets and cleanup failures never fall back to closing a window. Modal restrictions follow the [shortcut service](../shortcuts/README.md). The close command first requests the foreground modal’s close action; a modal that declines dismissal, a menu, or an unregistered dialog blocks closing the sidebar or window behind it. Closing the sole docked guide with a shortcut or native menu collapses the sidebar and retains the guide. Desktop requests window closure through the shortcut service only when no modal is open and no sidebar page owns focus; Web leaves the browser open.
|
||||
|
||||
<a id="the-guide"></a>
|
||||
## The guide
|
||||
|
||||
@@ -107,7 +107,7 @@ Tab 域按(Session,Tab id)保留品牌化 occurrence id、导航、中止
|
||||
|
||||
标签页所有者通过 effect 注册 `registerCloseHandler(kind, handler)`。handler 在允许显式关闭或替换前同步保存后台清理任务。资源所有者跟踪完成和重试,侧栏不等待清理。handler 抛错时保留标签页。折叠、展示方式改变和插件卸载不调用关闭 handler;tab abort signal 标识 occurrence 卸载,不代表显式关闭。
|
||||
|
||||
关闭与刷新命令解析聚焦 pane 和 tab occurrence。页面通过 `tab.actions.bindCommands()` 绑定刷新操作,并在卸载时释放;已结束的 occurrence 不能重新获得能力。刷新不会重载应用。通过快捷键打开页面、展开侧栏或执行分栏操作后,焦点在 DOM 提交后移至选中的停靠或浮动分栏,包括显示已有单例页面和替换引导页。展开侧栏时聚焦活动停靠分栏;收起时保留浮动分栏内的焦点。分栏获得焦点时保留现有文本选区。聚焦的页面输入框或 iframe 被替换时,仅在没有其他元素持有焦点的情况下将焦点交还其可见分栏;外部指针操作或显式失焦会取消此次恢复。关闭会再次检查捕获的身份并执行资源清理。通过快捷键或原生菜单关闭后,焦点移至同一 Session 中仍可见的分栏,让连续关闭继续作用于右栏。过期目标和清理失败均不会回退为关窗。模态限制遵循[快捷键服务](../shortcuts/README.zh.md);Windows 和 macOS Desktop 绑定优先于模态控件。通过快捷键或原生菜单关闭唯一的停靠引导页时,收起右栏并保留引导页。Desktop 仅在焦点没有所属的右栏页面时通过快捷键服务请求关闭窗口;Web 保留浏览器窗口。
|
||||
关闭与刷新命令解析聚焦 pane 和 tab occurrence。页面通过 `tab.actions.bindCommands()` 绑定刷新操作,并在卸载时释放;已结束的 occurrence 不能重新获得能力。刷新不会重载应用。通过快捷键打开页面、展开侧栏或执行分栏操作后,焦点在 DOM 提交后移至选中的停靠或浮动分栏,包括显示已有单例页面和替换引导页。展开侧栏时聚焦活动停靠分栏;收起时保留浮动分栏内的焦点。分栏获得焦点时保留现有文本选区。聚焦的页面输入框或 iframe 被替换时,仅在没有其他元素持有焦点的情况下将焦点交还其可见分栏;外部指针操作或显式失焦会取消此次恢复。关闭会再次检查捕获的身份并执行资源清理。通过快捷键或原生菜单关闭后,焦点移至同一 Session 中仍可见的分栏,让连续关闭继续作用于右栏。过期目标和清理失败均不会回退为关窗。模态限制遵循[快捷键服务](../shortcuts/README.zh.md)。关闭命令优先请求前台弹窗的关闭操作;弹窗拒绝关闭、菜单或未注册的对话框都会阻止关闭后方侧栏或窗口。通过快捷键或原生菜单关闭唯一的停靠引导页时,收起右栏并保留引导页。Desktop 仅在没有弹窗且焦点没有所属的右栏页面时通过快捷键服务请求关闭窗口;Web 保留浏览器窗口。
|
||||
|
||||
<a id="the-guide"></a>
|
||||
## 引导页
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Sidebar-owned commands resolved against the currently mounted page. */
|
||||
import type { Shortcuts, ShortcutBinding, ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client'
|
||||
import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { closeTopModal } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SidebarRightController } from './service.ts'
|
||||
import type { SidebarRightTarget } from './focus.ts'
|
||||
import type {} from './locales.ts'
|
||||
@@ -68,8 +69,11 @@ export function registerSidebarShortcuts(shortcuts: Pick<Shortcuts, 'register' |
|
||||
id: `page.${kind}` as ShortcutCommandId, label: () => t(`command.${kind}`), aliases: [kind, 'page'],
|
||||
defaults: { 'desktop:macos': desktop, 'desktop:windows': desktop, 'desktop:linux': desktop,
|
||||
'web:windows': web, ...(kind === 'close' ? { 'web:macos': web } : {}) },
|
||||
regions: ['page', 'editable', 'terminal'], modals: [],
|
||||
resolve: ({ target: element, source }) => {
|
||||
regions: ['page', 'editable', 'terminal'], modals: kind === 'close' ? ['settings', 'shortcuts', 'other'] : [],
|
||||
resolve: ({ target: element, source, modal }) => {
|
||||
if (kind === 'close' && modal !== null) {
|
||||
return { status: 'handled', run: () => { closeTopModal(document) } }
|
||||
}
|
||||
const target = sidebar.focusedTarget(element)
|
||||
if (target === undefined && (source === 'iframe' || element?.closest('[data-sidebar-right-session]'))) {
|
||||
return { status: 'blocked', reason: t('command.stale') }
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Focus ownership remains separate from layout selection and validates captured lifetimes. */
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { Modal } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { activeDockPaneId, findTabPane, getPane } from '@deepseek-ai/dsh-client-ui-dockkit'
|
||||
import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
|
||||
@@ -17,7 +20,7 @@ import { observeSidebarFocus, sidebarTargetFromElement } from '../src/client/foc
|
||||
|
||||
const SESSION = 'focus-session' as SessionId
|
||||
const releases: Array<() => void> = []
|
||||
afterEach(() => { for (const release of releases.splice(0).reverse()) release(); document.body.replaceChildren() })
|
||||
afterEach(() => { cleanup(); for (const release of releases.splice(0).reverse()) release(); document.body.replaceChildren() })
|
||||
|
||||
function harness() {
|
||||
const ctx = new Context()
|
||||
@@ -517,7 +520,7 @@ describe('page close and refresh', () => {
|
||||
expect(h.layout().tabs[remaining.id]).toBeDefined()
|
||||
})
|
||||
|
||||
it.each(['macos', 'windows'] as const)('refreshes and closes the focused occurrence on %s with priority over modals and terminals', (platform) => {
|
||||
it.each(['macos', 'windows'] as const)('refreshes and closes the focused occurrence on %s without closing behind a modal', (platform) => {
|
||||
const h = harness()
|
||||
const registry = new ShortcutRegistry('desktop', platform)
|
||||
const closeWindow = vi.fn()
|
||||
@@ -542,6 +545,8 @@ describe('page close and refresh', () => {
|
||||
expect(h.layout().tabs[files]).toBeDefined()
|
||||
expect(registry.dispatch(gesture, { ...context, region: 'terminal' }, consume).status).toBe('blocked')
|
||||
expect(registry.dispatch({ ...gesture, code: 'KeyW' }, { ...context, region: 'terminal', modal: 'settings' }, consume).status).toBe('handled')
|
||||
expect(h.layout().tabs[files]).toBeDefined()
|
||||
registry.dispatch({ ...gesture, code: 'KeyW' }, { ...context, region: 'terminal' }, consume)
|
||||
expect(h.layout().tabs[files]).toBeUndefined()
|
||||
expect(occurrence.signal.aborted).toBe(true)
|
||||
registry.dispatch({ ...gesture, code: 'KeyW' }, context, consume)
|
||||
@@ -550,6 +555,26 @@ describe('page close and refresh', () => {
|
||||
expect(closeWindow).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each(['macos', 'windows', 'linux'] as const)('routes the close binding and native menu to the foreground modal on %s', (platform) => {
|
||||
const h = harness()
|
||||
const registry = new ShortcutRegistry('desktop', platform)
|
||||
const closeWindow = vi.fn(), closeModal = vi.fn()
|
||||
releases.push(registerSidebarShortcuts({ register: command => registry.register(command), runtime: 'desktop' }, h.controller, makeTranslate(en), closeWindow))
|
||||
render(createElement(Modal, { open: true, title: 'Settings', shortcutModal: 'settings', closeLabel: 'Close', onClose: closeModal }))
|
||||
const context = { target: document.activeElement, region: 'page' as const, modal: 'settings' }
|
||||
const gesture = { code: 'KeyW', meta: platform === 'macos', control: platform !== 'macos', alt: false,
|
||||
shift: false, repeat: false, composing: false, defaultPrevented: false }
|
||||
registry.dispatch({ ...gesture, composing: true }, context, vi.fn())
|
||||
registry.dispatch({ ...gesture, repeat: true }, context, vi.fn())
|
||||
expect(closeModal).not.toHaveBeenCalled()
|
||||
registry.dispatch(gesture, context, vi.fn())
|
||||
expect(closeModal).toHaveBeenCalledTimes(1)
|
||||
registry.invoke('page.close' as ShortcutCommandId, context)
|
||||
expect(closeModal).toHaveBeenCalledTimes(2)
|
||||
expect(closeWindow).not.toHaveBeenCalled()
|
||||
expect(h.layout().expanded).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves a page when cleanup fails and never falls back to closing the window', () => {
|
||||
const h = harness()
|
||||
h.controller.openTab('terminal')
|
||||
|
||||
@@ -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-theme/README.md
|
||||
README.md: 057509104769fc24f669e5a016be0b2adfc08922
|
||||
README.zh.md: da43152e84356cb3a70b8980c310d62204535dc2
|
||||
README.md: 69a91b5ab8be6f194f33c6b18a394fee3cf266eb
|
||||
README.zh.md: f619065e081ea7f27066bf08b16fef5962617d68
|
||||
@@ -53,6 +53,8 @@ The service owns theme and font-size state and publishes snapshots. The ui-layou
|
||||
|
||||
`src/styles/` holds six sheets imported in order by ui-theme's dynamic client entry: `base.css`, `corner-shape.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. The client bundle compiles and injects them as plugin-owned global styles, so unload and HMR remove them with ui-theme. `scrollbar.css` consumes the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them. Status marks use their own semantic state tokens. `design-platform.css` also owns the code-diff fill aliases and their static alpha palette entries; `shiki.css` owns syntax colors.
|
||||
|
||||
`base.css` suppresses only the outline of focused elements marked `data-dsh-automatic-focus` by the [primitive focus helper](../ui-primitives/README.md); ordinary keyboard focus styling, borders, shadows, and error states remain intact.
|
||||
|
||||
System toasts use `--dsw-alias-toast-bg` and `--dsw-alias-toast-label` for a shared background and text color across callers.
|
||||
|
||||
`brand-font.css` exports the local Montserrat Light, Regular and Medium faces (normal style, weights 300, 400 and 500), with `montserrat-light.woff2`, `montserrat-regular.woff2`, `montserrat-medium.woff2` and its SIL Open Font License in `lib/styles/`. Desktop bundles the same stylesheet, font and license for offline welcome brand text; ordinary UI keeps its system font stack.
|
||||
|
||||
@@ -53,6 +53,8 @@ kind: "package-reference"
|
||||
|
||||
`src/styles/` 下有六张样式表,由 ui-theme 的动态客户端 entry 依次导入:`base.css`、`corner-shape.css`、`design-platform.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。客户端 bundle 将其编译并注入为插件持有的全局样式,因此卸载与 HMR(热模块替换)会随 ui-theme 一同移除。`scrollbar.css` 消费 `--dsw-alias-scrollbar-*` token,必须排在声明这些 token 的 `design-platform.css` 之后。状态标记使用各自的语义状态 token。`design-platform.css` 还负责代码差异底色的别名及其静态透明度色阶;`shiki.css` 负责语法颜色。
|
||||
|
||||
`base.css` 仅抑制[基础控件焦点工具](../ui-primitives/README.zh.md)通过 `data-dsh-automatic-focus` 标记的聚焦元素外轮廓线;正常键盘焦点样式、边框、阴影及错误状态保持不变。
|
||||
|
||||
系统提示使用 `--dsw-alias-toast-bg` 和 `--dsw-alias-toast-label`,在各调用方之间统一背景与文字颜色。
|
||||
|
||||
`brand-font.css` 导出本地 Montserrat Light、Regular 和 Medium 字体(正体、字重 300、400 和 500),`lib/styles/` 同时提供 `montserrat-light.woff2`、`montserrat-regular.woff2`、`montserrat-medium.woff2` 及其 SIL Open Font License。Desktop 将同一份样式表、字体和许可证打包,用于欢迎页品牌文字的离线显示;普通界面保留系统字体栈。
|
||||
|
||||
@@ -13,3 +13,8 @@
|
||||
--ds-transition-duration-fast: 0.1s;
|
||||
--ds-transition-duration-slow: 0.3s;
|
||||
}
|
||||
|
||||
[data-dsh-automatic-focus]:focus,
|
||||
[data-dsh-automatic-focus]:focus:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Automatic focus presentation and subsequent keyboard navigation use the shipped theme sheet. */
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { afterEach, beforeEach, expect, it } from 'vitest'
|
||||
import { focusWithoutRing } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
|
||||
const base = readFileSync(resolve(import.meta.dirname, '../src/styles/base.css'), 'utf8')
|
||||
const outline = '2px solid rgb(200, 100, 0)'
|
||||
|
||||
beforeEach(() => {
|
||||
const style = document.createElement('style')
|
||||
style.textContent = `button:focus { outline: ${outline}; border: 1px solid red; box-shadow: 0 0 4px black; }\n${base}`
|
||||
document.head.append(style)
|
||||
})
|
||||
afterEach(() => { document.head.replaceChildren(); document.body.replaceChildren() })
|
||||
|
||||
function control(): HTMLButtonElement {
|
||||
const button = document.createElement('button')
|
||||
document.body.append(button)
|
||||
return button
|
||||
}
|
||||
|
||||
it('omits automatic outlines through class updates while keeping borders and shadows', () => {
|
||||
const button = control()
|
||||
button.focus()
|
||||
const { border, boxShadow } = getComputedStyle(button)
|
||||
expect(getComputedStyle(button).outline).toBe(outline)
|
||||
focusWithoutRing(button)
|
||||
button.className = 'selected'
|
||||
expect(document.activeElement).toBe(button)
|
||||
expect(getComputedStyle(button).outline).toBe('none')
|
||||
expect(getComputedStyle(button).border).toBe(border)
|
||||
expect(getComputedStyle(button).boxShadow).toBe(boxShadow)
|
||||
})
|
||||
|
||||
it('restores navigation outlines after repeated automatic focus without consuming input', () => {
|
||||
const button = control()
|
||||
for (const init of [{ key: 'Tab' }, { key: 'Tab', shiftKey: true }, { key: 'ArrowDown' }, { key: 'Home' }]) {
|
||||
focusWithoutRing(button)
|
||||
focusWithoutRing(button, { preventScroll: true })
|
||||
const event = new KeyboardEvent('keydown', { ...init, bubbles: true, cancelable: true })
|
||||
button.dispatchEvent(event)
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
expect(getComputedStyle(button).outline).toBe(outline)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps typing, modifier shortcuts and input-method navigation free of automatic outlines', () => {
|
||||
const button = control()
|
||||
focusWithoutRing(button)
|
||||
for (const init of [{ key: 'Enter' }, { key: 'a' }, { key: 'ArrowDown', isComposing: true },
|
||||
{ key: 'ArrowDown', ctrlKey: true }, { key: 'ArrowDown', altKey: true }, { key: 'ArrowDown', metaKey: true }]) {
|
||||
button.dispatchEvent(new KeyboardEvent('keydown', init))
|
||||
expect(getComputedStyle(button).outline).toBe('none')
|
||||
}
|
||||
button.blur()
|
||||
button.focus()
|
||||
expect(getComputedStyle(button).outline).toBe(outline)
|
||||
})
|
||||
|
||||
it('does not suppress later keyboard focus when an automatic target cannot receive focus', () => {
|
||||
const button = control()
|
||||
button.disabled = true
|
||||
focusWithoutRing(button)
|
||||
expect(document.activeElement).not.toBe(button)
|
||||
button.disabled = false
|
||||
button.focus()
|
||||
expect(getComputedStyle(button).outline).toBe(outline)
|
||||
})
|
||||
@@ -279,6 +279,7 @@ async function bench(snapshot = historySnapshot(NODES)) {
|
||||
}
|
||||
const binding: ConversationBinding = {
|
||||
snapshot: conversationStore,
|
||||
openTurn: createSnapshotStore<number | undefined>(undefined),
|
||||
activate: () => {},
|
||||
target: target => targetSources[target],
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user