feat: enrich project selector details
The project selector previously used a native list that showed only project names and inherited platform-specific styling. It now presents an accessible rich menu grouped into local projects and remote channels, with folder or channel context for each option. Release note: 项目选择器现在按本地项目和远程通道分组,并显示目录或通道来源,切换项目时更容易辨认目标。
This commit is contained in:
@@ -4706,6 +4706,52 @@ describe('App', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('shows grouped project details and keeps the rich menu keyboard accessible', async () => {
|
||||
const channelProject = {
|
||||
...project,
|
||||
id: '00000000-0000-4000-8000-000000000201',
|
||||
name: '微信 ClawBot',
|
||||
kind: 'channel' as const,
|
||||
channel: 'weixin' as const
|
||||
}
|
||||
vi.mocked(api.projects.list).mockResolvedValueOnce([
|
||||
project,
|
||||
channelProject
|
||||
])
|
||||
render(<App />)
|
||||
|
||||
const trigger = await screen.findByRole('button', {
|
||||
name: '当前项目'
|
||||
})
|
||||
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
|
||||
|
||||
const menu = screen.getByRole('menu', { name: '当前项目' })
|
||||
expect(
|
||||
within(menu).getByRole('group', { name: '本地项目' })
|
||||
).toHaveTextContent('本地目录 · C:\\Users\\test')
|
||||
expect(
|
||||
within(menu).getByRole('group', { name: '远程通道' })
|
||||
).toHaveTextContent(
|
||||
'微信 ClawBot · 远程通道 · C:\\Users\\test'
|
||||
)
|
||||
const selectedProject = within(menu)
|
||||
.getByText(project.name, { selector: 'b' })
|
||||
.closest<HTMLButtonElement>('[role="menuitemradio"]')
|
||||
const remoteProject = within(menu)
|
||||
.getByText(channelProject.name, { selector: 'b' })
|
||||
.closest<HTMLButtonElement>('[role="menuitemradio"]')
|
||||
expect(selectedProject).toHaveAttribute('aria-checked', 'true')
|
||||
await waitFor(() => expect(selectedProject).toHaveFocus())
|
||||
|
||||
fireEvent.keyDown(selectedProject!, { key: 'End' })
|
||||
expect(remoteProject).toHaveFocus()
|
||||
fireEvent.keyDown(remoteProject!, { key: 'Escape' })
|
||||
expect(trigger).toHaveFocus()
|
||||
expect(
|
||||
screen.queryByRole('menu', { name: '当前项目' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps channel projects empty until a client message creates a remote conversation', async () => {
|
||||
const channelProject = {
|
||||
...project,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import {
|
||||
Archive,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Plus,
|
||||
RadioTower,
|
||||
Settings,
|
||||
Trash2,
|
||||
X
|
||||
@@ -15,7 +19,8 @@ import type {
|
||||
} from '../../shared/assistant-contracts'
|
||||
import {
|
||||
interactiveWorkModes,
|
||||
normalizeInteractiveWorkMode
|
||||
normalizeInteractiveWorkMode,
|
||||
projectChannelLabels
|
||||
} from '../../shared/assistant-contracts'
|
||||
import type { RuntimeSettings } from '../../shared/contracts'
|
||||
import { trapTabFocus } from './dialog-focus'
|
||||
@@ -66,10 +71,14 @@ export function ProjectSwitcher({
|
||||
const [error, setError] = useState<string>()
|
||||
const createButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const settingsButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const projectPickerRef = useRef<HTMLDivElement>(null)
|
||||
const projectPickerButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const projectPickerMenuRef = useRef<HTMLDivElement>(null)
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const restoreFocusTarget = useRef<
|
||||
'create' | 'settings' | undefined
|
||||
>(undefined)
|
||||
const [projectMenuOpen, setProjectMenuOpen] = useState(false)
|
||||
const [draft, setDraft] = useState<ProjectCreateInput>({
|
||||
name: '',
|
||||
description: '',
|
||||
@@ -87,6 +96,38 @@ export function ProjectSwitcher({
|
||||
)
|
||||
const busy = saving || archiving || deleting
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectMenuOpen) {
|
||||
return
|
||||
}
|
||||
const focusFrame = requestAnimationFrame(() => {
|
||||
const items = Array.from(
|
||||
projectPickerMenuRef.current?.querySelectorAll<HTMLButtonElement>(
|
||||
'[role="menuitemradio"]'
|
||||
) ?? []
|
||||
)
|
||||
const initialItem =
|
||||
items.find((item) => item.getAttribute('aria-checked') === 'true') ??
|
||||
items[0]
|
||||
initialItem?.focus()
|
||||
})
|
||||
const closeOutside = (event: Event): void => {
|
||||
if (
|
||||
event.target instanceof Node &&
|
||||
!projectPickerRef.current?.contains(event.target)
|
||||
) {
|
||||
setProjectMenuOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('pointerdown', closeOutside)
|
||||
document.addEventListener('focusin', closeOutside)
|
||||
return () => {
|
||||
cancelAnimationFrame(focusFrame)
|
||||
document.removeEventListener('pointerdown', closeOutside)
|
||||
document.removeEventListener('focusin', closeOutside)
|
||||
}
|
||||
}, [projectMenuOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!dialogMode) {
|
||||
if (restoreFocusTarget.current === 'create') {
|
||||
@@ -207,36 +248,150 @@ export function ProjectSwitcher({
|
||||
return (
|
||||
<div className="project-switcher">
|
||||
<div className="project-switcher__row">
|
||||
<select
|
||||
aria-label={t('projectSwitcher.selector.ariaLabel')}
|
||||
onChange={(event) => onSelect(event.target.value)}
|
||||
value={activeProjectId}
|
||||
<div
|
||||
className="project-switcher__picker"
|
||||
ref={projectPickerRef}
|
||||
>
|
||||
{userProjects.length > 0 && (
|
||||
<optgroup label={t('projectSwitcher.selector.userProjects')}>
|
||||
{userProjects.map((project) => (
|
||||
<option key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{channelProjects.length > 0 && (
|
||||
<optgroup
|
||||
label={t('projectSwitcher.selector.channelProjects')}
|
||||
<button
|
||||
aria-expanded={projectMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
aria-label={t('projectSwitcher.selector.ariaLabel')}
|
||||
className="project-switcher__trigger"
|
||||
onClick={() => setProjectMenuOpen((open) => !open)}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
!projectMenuOpen &&
|
||||
(event.key === 'ArrowDown' ||
|
||||
event.key === 'Enter' ||
|
||||
event.key === ' ')
|
||||
) {
|
||||
event.preventDefault()
|
||||
setProjectMenuOpen(true)
|
||||
}
|
||||
}}
|
||||
ref={projectPickerButtonRef}
|
||||
title={activeProject?.name}
|
||||
type="button"
|
||||
>
|
||||
<span>{activeProject?.name ?? t('projectSwitcher.selector.empty')}</span>
|
||||
<ChevronDown aria-hidden="true" size={14} />
|
||||
</button>
|
||||
{projectMenuOpen && (
|
||||
<div
|
||||
aria-label={t('projectSwitcher.selector.ariaLabel')}
|
||||
className="project-switcher__menu"
|
||||
onKeyDown={(event) => {
|
||||
const items = Array.from(
|
||||
event.currentTarget.querySelectorAll<HTMLButtonElement>(
|
||||
'[role="menuitemradio"]'
|
||||
)
|
||||
)
|
||||
const currentIndex = items.indexOf(
|
||||
document.activeElement as HTMLButtonElement
|
||||
)
|
||||
let nextIndex: number | undefined
|
||||
if (event.key === 'ArrowDown') {
|
||||
nextIndex = (currentIndex + 1) % items.length
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
nextIndex =
|
||||
(currentIndex - 1 + items.length) % items.length
|
||||
} else if (event.key === 'Home') {
|
||||
nextIndex = 0
|
||||
} else if (event.key === 'End') {
|
||||
nextIndex = items.length - 1
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
setProjectMenuOpen(false)
|
||||
projectPickerButtonRef.current?.focus()
|
||||
}
|
||||
const nextItem =
|
||||
nextIndex === undefined ? undefined : items.at(nextIndex)
|
||||
if (nextItem) {
|
||||
event.preventDefault()
|
||||
items.forEach((item) => {
|
||||
item.tabIndex = item === nextItem ? 0 : -1
|
||||
})
|
||||
nextItem.focus()
|
||||
}
|
||||
}}
|
||||
ref={projectPickerMenuRef}
|
||||
role="menu"
|
||||
>
|
||||
{channelProjects.map((project) => (
|
||||
<option key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
{[
|
||||
{
|
||||
projects: userProjects,
|
||||
label: t('projectSwitcher.selector.userProjects'),
|
||||
icon: Folder
|
||||
},
|
||||
{
|
||||
projects: channelProjects,
|
||||
label: t('projectSwitcher.selector.channelProjects'),
|
||||
icon: RadioTower
|
||||
}
|
||||
].map((group) =>
|
||||
group.projects.length > 0 ? (
|
||||
<div
|
||||
aria-label={group.label}
|
||||
className="project-switcher__group"
|
||||
key={group.label}
|
||||
role="group"
|
||||
>
|
||||
<strong>{group.label}</strong>
|
||||
{group.projects.map((project) => {
|
||||
const selected = project.id === activeProjectId
|
||||
const ProjectIcon = group.icon
|
||||
const detail =
|
||||
project.kind === 'channel'
|
||||
? t('projectSwitcher.selector.remoteDetail', {
|
||||
channel: project.channel
|
||||
? projectChannelLabels[project.channel]
|
||||
: t(
|
||||
'projectSwitcher.selector.remoteChannel'
|
||||
),
|
||||
path: project.rootPath
|
||||
})
|
||||
: t('projectSwitcher.selector.localDetail', {
|
||||
path: project.rootPath
|
||||
})
|
||||
return (
|
||||
<button
|
||||
aria-checked={selected}
|
||||
key={project.id}
|
||||
onClick={() => {
|
||||
if (!selected) {
|
||||
onSelect(project.id)
|
||||
}
|
||||
setProjectMenuOpen(false)
|
||||
requestAnimationFrame(() =>
|
||||
projectPickerButtonRef.current?.focus()
|
||||
)
|
||||
}}
|
||||
role="menuitemradio"
|
||||
tabIndex={selected ? 0 : -1}
|
||||
type="button"
|
||||
>
|
||||
<ProjectIcon aria-hidden="true" size={16} />
|
||||
<span>
|
||||
<b>{project.name}</b>
|
||||
<small>{detail}</small>
|
||||
</span>
|
||||
{selected && (
|
||||
<Check aria-hidden="true" size={14} />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
aria-label={t('projectSwitcher.selector.create')}
|
||||
className="icon-button"
|
||||
onClick={() => {
|
||||
setProjectMenuOpen(false)
|
||||
setError(undefined)
|
||||
setConfirmingDelete(false)
|
||||
setDeleteConfirmation('')
|
||||
@@ -265,6 +420,7 @@ export function ProjectSwitcher({
|
||||
if (!activeProject) {
|
||||
return
|
||||
}
|
||||
setProjectMenuOpen(false)
|
||||
setError(undefined)
|
||||
setConfirmingDelete(false)
|
||||
setDeleteConfirmation('')
|
||||
|
||||
@@ -150,6 +150,15 @@ describe('WorkspacePrimitives', () => {
|
||||
expect(stylesheet).not.toContain('.conversation-delete')
|
||||
})
|
||||
|
||||
it('uses the shared control and menu surfaces for the rich project picker', () => {
|
||||
expect(stylesheet).toMatch(
|
||||
/\.project-switcher__trigger\s*\{[^}]*height:\s*var\(--control-height\);[^}]*border-radius:\s*var\(--radius-control\);[^}]*background:\s*var\(--surface-raised\);/u
|
||||
)
|
||||
expect(stylesheet).toMatch(
|
||||
/\.project-switcher__menu\s*\{[^}]*border-radius:\s*var\(--radius-card\);[^}]*background:\s*var\(--surface-raised\);[^}]*box-shadow:\s*var\(--shadow-dialog\);/u
|
||||
)
|
||||
})
|
||||
|
||||
it('separates Runtime-specific controls from the main composer toolbar', () => {
|
||||
expect(stylesheet).toMatch(
|
||||
/\.composer__toolbar--with-runtime-controls\s*\{[^}]*border-radius:\s*0;/u
|
||||
|
||||
@@ -9,8 +9,12 @@ export const workspace = {
|
||||
},
|
||||
selector: {
|
||||
ariaLabel: 'Current project',
|
||||
userProjects: 'Projects',
|
||||
userProjects: 'Local projects',
|
||||
channelProjects: 'Remote channels',
|
||||
empty: 'No project selected',
|
||||
localDetail: 'Local folder · {{path}}',
|
||||
remoteDetail: '{{channel}} · Remote channel · {{path}}',
|
||||
remoteChannel: 'Remote channel',
|
||||
create: 'New project',
|
||||
settings: 'Project settings'
|
||||
},
|
||||
|
||||
@@ -6,8 +6,12 @@ export const workspace = {
|
||||
},
|
||||
selector: {
|
||||
ariaLabel: '当前项目',
|
||||
userProjects: '普通项目',
|
||||
userProjects: '本地项目',
|
||||
channelProjects: '远程通道',
|
||||
empty: '未选择项目',
|
||||
localDetail: '本地目录 · {{path}}',
|
||||
remoteDetail: '{{channel}} · 远程通道 · {{path}}',
|
||||
remoteChannel: '远程通道',
|
||||
create: '新建项目',
|
||||
settings: '项目设置'
|
||||
},
|
||||
|
||||
+108
-3
@@ -2317,15 +2317,120 @@ textarea:focus-visible {
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
}
|
||||
|
||||
.project-switcher select {
|
||||
width: 100%;
|
||||
.project-switcher__picker {
|
||||
min-width: 0;
|
||||
padding: 8px 9px;
|
||||
}
|
||||
|
||||
.project-switcher__trigger {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: var(--control-height);
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
padding: 0 var(--space-3);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-body);
|
||||
font-weight: 600;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.project-switcher__trigger:hover,
|
||||
.project-switcher__trigger[aria-expanded="true"] {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.project-switcher__trigger > span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.project-switcher__menu {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
top: calc(var(--control-height) + var(--space-2));
|
||||
right: 0;
|
||||
left: 0;
|
||||
display: grid;
|
||||
max-height: min(420px, calc(100vh - 160px));
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-card);
|
||||
overflow-y: auto;
|
||||
background: var(--surface-raised);
|
||||
box-shadow: var(--shadow-dialog);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.project-switcher__group {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.project-switcher__group + .project-switcher__group {
|
||||
padding-top: var(--space-2);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.project-switcher__group > strong {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.project-switcher__group > button {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 52px;
|
||||
align-items: center;
|
||||
padding: var(--space-2);
|
||||
border-radius: var(--radius-control);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
gap: var(--space-2);
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.project-switcher__group > button:hover,
|
||||
.project-switcher__group > button[aria-checked="true"] {
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.project-switcher__group > button > span {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.project-switcher__group > button b,
|
||||
.project-switcher__group > button small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.project-switcher__group > button b {
|
||||
font-size: var(--font-body);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.project-switcher__group > button small {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.project-create-backdrop {
|
||||
|
||||
Reference in New Issue
Block a user