mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-30 16:23:35 +08:00
The "Project" scope on a skill's activation panel was single-select:
choosing a different project from the dropdown silently moved the
activation, so a skill could only be active in one project at a time.
Make it multi-select instead — a skill can now be active in any
combination of projects independently.
UI
- New shared MultiSelectDropdown component (role="menuitemcheckbox",
toggle keeps the popover open, Escape / outside click close).
- SkillActivationScope's project picker uses it. The trigger now shows
the count: "Select projects…" / one path / "{n} projects".
- The high-level Off / Global / Project buttons keep working: tapping
"Project" while no project is active default-activates the current
one; tapping it again is a no-op so it can't wipe a multi-selection.
- Activating any project also clears the global flag (mutually
exclusive); toggling a project off does not touch global.
State
- activeProjects: Set<projectPath> replaces the old single
selectedProjectPath + projectSkills pair.
- Loaded by fanning out one getActiveSkills('project', cwd) per
resolved project, and collecting paths whose list contains this
skill into the Set.
- Each toggle is read-modify-write on a single project's
activeSkills, so a failure on one project never corrupts the others.
No backend, jsonl format, or migration changes — purely a
front-end aggregation over the existing per-project API.
Tests
- 4 new tests for MultiSelectDropdown (visibility, ARIA, multi-toggle
doesn't auto-close, Escape).
- 1 new SkillDetail test asserting the "{count} projects" trigger
surfaces when multiple projects are active.
- All 4 existing SkillDetail tests still pass — verification subagent
confirmed the multi-select gate (no-op when size>0) and global/
project mutual exclusion.
- 5 locales each gain 2 new keys (selectProjects + projectCount).
Co-authored-by: 你的姓名 <you@example.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
82 lines
2.5 KiB
TypeScript
82 lines
2.5 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest'
|
|
import { render, screen, fireEvent } from '@testing-library/react'
|
|
import '@testing-library/jest-dom'
|
|
|
|
import { MultiSelectDropdown } from './MultiSelectDropdown'
|
|
|
|
type Item = { value: string; label: string }
|
|
|
|
function renderDropdown(props: {
|
|
values: string[]
|
|
items: Item[]
|
|
onToggle?: (v: string) => void
|
|
}) {
|
|
return render(
|
|
<MultiSelectDropdown
|
|
values={props.values}
|
|
onToggle={props.onToggle ?? (() => {})}
|
|
items={props.items}
|
|
trigger={<button>open</button>}
|
|
/>,
|
|
)
|
|
}
|
|
|
|
describe('MultiSelectDropdown', () => {
|
|
it('hides items until the trigger is clicked', () => {
|
|
renderDropdown({
|
|
values: [],
|
|
items: [{ value: 'a', label: 'Alpha' }, { value: 'b', label: 'Beta' }],
|
|
})
|
|
expect(screen.queryByText('Alpha')).not.toBeInTheDocument()
|
|
|
|
fireEvent.click(screen.getByText('open'))
|
|
expect(screen.getByText('Alpha')).toBeInTheDocument()
|
|
expect(screen.getByText('Beta')).toBeInTheDocument()
|
|
})
|
|
|
|
it('exposes each row as menuitemcheckbox with correct aria-checked', () => {
|
|
renderDropdown({
|
|
values: ['a'],
|
|
items: [{ value: 'a', label: 'Alpha' }, { value: 'b', label: 'Beta' }],
|
|
})
|
|
fireEvent.click(screen.getByText('open'))
|
|
|
|
const rows = screen.getAllByRole('menuitemcheckbox')
|
|
expect(rows).toHaveLength(2)
|
|
expect(rows[0]).toHaveAttribute('aria-checked', 'true')
|
|
expect(rows[1]).toHaveAttribute('aria-checked', 'false')
|
|
})
|
|
|
|
it('keeps the popover open after toggling so multiple items can be chosen', () => {
|
|
const onToggle = vi.fn()
|
|
renderDropdown({
|
|
values: [],
|
|
items: [{ value: 'a', label: 'Alpha' }, { value: 'b', label: 'Beta' }],
|
|
onToggle,
|
|
})
|
|
fireEvent.click(screen.getByText('open'))
|
|
|
|
fireEvent.click(screen.getByText('Alpha'))
|
|
fireEvent.click(screen.getByText('Beta'))
|
|
|
|
// Both fires AND the menu items are still on screen — popover never auto-closed.
|
|
expect(onToggle).toHaveBeenCalledTimes(2)
|
|
expect(onToggle).toHaveBeenNthCalledWith(1, 'a')
|
|
expect(onToggle).toHaveBeenNthCalledWith(2, 'b')
|
|
expect(screen.getByText('Alpha')).toBeInTheDocument()
|
|
expect(screen.getByText('Beta')).toBeInTheDocument()
|
|
})
|
|
|
|
it('closes when Escape is pressed', () => {
|
|
renderDropdown({
|
|
values: [],
|
|
items: [{ value: 'a', label: 'Alpha' }],
|
|
})
|
|
fireEvent.click(screen.getByText('open'))
|
|
expect(screen.getByText('Alpha')).toBeInTheDocument()
|
|
|
|
fireEvent.keyDown(document, { key: 'Escape' })
|
|
expect(screen.queryByText('Alpha')).not.toBeInTheDocument()
|
|
})
|
|
})
|