mirror of
https://github.com/stablyai/orca.git
synced 2026-09-24 08:02:33 +00:00
Make worktree palette hint rows keyboard-clickable (#17272)
* Make worktree palette hint rows keyboard-clickable Hint entries like "See more" are now CommandItems that can be navigated with arrow keys and activated with Enter, instead of being non-interactive divs. This allows keyboard-only users to access the expand actions without mouse interaction. * Make worktree palette "See more" keyboard-navigable Preserve cursor position when expanding via keyboard: auto-select the first newly revealed item at the previous index and restore input focus.
This commit is contained in:
@@ -8,6 +8,7 @@ import React, {
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
@@ -42,7 +43,6 @@ import {
|
||||
CommandEmpty,
|
||||
CommandItem
|
||||
} from '@/components/ui/command'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { parseGitHubIssueOrPRNumber, parseGitHubIssueOrPRLink } from '@/lib/github-links'
|
||||
import { getLinkedWorkItemSuggestedName, getLinkedWorkItemWorkspaceName } from '@/lib/new-workspace'
|
||||
@@ -3258,30 +3258,33 @@ function WorktreeJumpPaletteContent({
|
||||
}
|
||||
|
||||
if (entry.type === 'hint') {
|
||||
// Why: plain div (not CommandItem) so cmdk can't select it; arrow keys skip it via selectableItems.
|
||||
return (
|
||||
<div
|
||||
<CommandItem
|
||||
key={renderKey}
|
||||
className="mx-0.5 mt-1 flex items-center gap-2 px-3 py-1.5 text-[12px] text-muted-foreground"
|
||||
value={renderKey}
|
||||
onSelect={() => {
|
||||
const previousIndex = selectionItemIds.indexOf(renderKey)
|
||||
flushSync(() => entry.onSeeMore?.())
|
||||
const expandedItemId = Array.from(
|
||||
listRef.current?.querySelectorAll<HTMLElement>('[cmdk-item]') ?? []
|
||||
)[previousIndex]?.getAttribute('data-value')
|
||||
if (expandedItemId) {
|
||||
setSelectedItemId(expandedItemId)
|
||||
}
|
||||
inputRef.current?.focus()
|
||||
}}
|
||||
className={cn(
|
||||
JUMP_PALETTE_ITEM_CLASSNAME,
|
||||
'mt-1 min-h-0 gap-2 py-1.5 text-[12px] text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{entry.label}</span>
|
||||
{entry.onSeeMore ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="h-6 shrink-0 px-2 text-xs font-medium text-foreground hover:bg-accent"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
entry.onSeeMore?.()
|
||||
inputRef.current?.focus()
|
||||
}}
|
||||
>
|
||||
<span className="h-6 shrink-0 rounded-md border border-input bg-background px-2 text-xs font-medium leading-6 text-foreground">
|
||||
{translate('worktreeJumpPalette.seeMore', 'See more')}
|
||||
</Button>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</CommandItem>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -57,18 +57,33 @@ vi.mock('@/components/ui/command', async () => {
|
||||
return {
|
||||
Command: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
CommandGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
CommandDialog: ({ children, open }: { children: React.ReactNode; open?: boolean }) =>
|
||||
open ? <div data-command-dialog="true">{children}</div> : null,
|
||||
CommandInput: ({
|
||||
value,
|
||||
onValueChange
|
||||
CommandDialog: ({
|
||||
children,
|
||||
open,
|
||||
commandProps
|
||||
}: {
|
||||
value?: string
|
||||
onValueChange?: (next: string) => void
|
||||
}) => {
|
||||
children: React.ReactNode
|
||||
open?: boolean
|
||||
commandProps?: { value?: string }
|
||||
}) =>
|
||||
open ? (
|
||||
<div data-command-dialog="true" data-command-value={commandProps?.value ?? ''}>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
CommandInput: React.forwardRef(function CommandInput(
|
||||
{
|
||||
value,
|
||||
onValueChange
|
||||
}: {
|
||||
value?: string
|
||||
onValueChange?: (next: string) => void
|
||||
},
|
||||
ref: React.ForwardedRef<HTMLInputElement>
|
||||
) {
|
||||
setCommandQuery = onValueChange ?? null
|
||||
return <input data-command-input="true" value={value} onChange={() => {}} />
|
||||
},
|
||||
return <input ref={ref} data-command-input="true" value={value} onChange={() => {}} />
|
||||
}),
|
||||
CommandList: React.forwardRef(function CommandList(
|
||||
{ children }: { children: React.ReactNode },
|
||||
ref: React.ForwardedRef<HTMLDivElement>
|
||||
@@ -82,8 +97,22 @@ vi.mock('@/components/ui/command', async () => {
|
||||
CommandEmpty: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-command-empty="true">{children}</div>
|
||||
),
|
||||
CommandItem: ({ children, value }: { children: React.ReactNode; value?: string }) => (
|
||||
<button data-command-item={value ?? ''} type="button">
|
||||
CommandItem: ({
|
||||
children,
|
||||
value,
|
||||
onSelect
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
value?: string
|
||||
onSelect?: () => void
|
||||
}) => (
|
||||
<button
|
||||
cmdk-item=""
|
||||
data-value={value ?? ''}
|
||||
data-command-item={value ?? ''}
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
@@ -534,6 +563,13 @@ describe('WorktreeJumpPalette interleaved primary sections', () => {
|
||||
btn.textContent?.includes('See more')
|
||||
)
|
||||
expect(seeMoreBtn).toBeDefined()
|
||||
const initialItemIds = Array.from(testContainer.querySelectorAll('[cmdk-item]')).map((item) =>
|
||||
item.getAttribute('data-value')
|
||||
)
|
||||
const seeMoreIndex = initialItemIds.indexOf('__hint_worktree_overflow__')
|
||||
expect(seeMoreIndex).toBeGreaterThan(0)
|
||||
const input = testContainer.querySelector<HTMLInputElement>('[data-command-input="true"]')
|
||||
input?.focus()
|
||||
|
||||
await act(async () => {
|
||||
seeMoreBtn?.click()
|
||||
@@ -541,9 +577,20 @@ describe('WorktreeJumpPalette interleaved primary sections', () => {
|
||||
await flushEffects()
|
||||
|
||||
// After expanding by 20: 30 worktrees are rendered, 5 more
|
||||
const renderedItems = testContainer.querySelectorAll('[data-command-item]')
|
||||
const renderedItems = testContainer.querySelectorAll('[data-command-item^="worktree:"]')
|
||||
expect(renderedItems).toHaveLength(30)
|
||||
expect(testContainer.textContent).toContain('5 more')
|
||||
const firstRevealedItemId = Array.from(testContainer.querySelectorAll('[cmdk-item]'))[
|
||||
seeMoreIndex
|
||||
]?.getAttribute('data-value')
|
||||
expect(firstRevealedItemId).toMatch(/^worktree:/)
|
||||
expect(firstRevealedItemId).not.toBe(initialItemIds[0])
|
||||
expect(
|
||||
testContainer
|
||||
.querySelector('[data-command-dialog="true"]')
|
||||
?.getAttribute('data-command-value')
|
||||
).toBe(firstRevealedItemId)
|
||||
expect(document.activeElement).toBe(input)
|
||||
|
||||
// Click again: 30 + 20 = 50 (all 35 fit), hint disappears
|
||||
const seeMoreBtn2 = Array.from(testContainer.querySelectorAll('button')).find((btn) =>
|
||||
@@ -554,7 +601,7 @@ describe('WorktreeJumpPalette interleaved primary sections', () => {
|
||||
})
|
||||
await flushEffects()
|
||||
|
||||
const renderedItemsAll = testContainer.querySelectorAll('[data-command-item]')
|
||||
const renderedItemsAll = testContainer.querySelectorAll('[data-command-item^="worktree:"]')
|
||||
expect(renderedItemsAll).toHaveLength(35)
|
||||
expect(testContainer.textContent).not.toContain('more')
|
||||
})
|
||||
|
||||
@@ -182,7 +182,7 @@ describe('worktree-palette-create-action', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('derives selection ids from rendered entries while skipping headers and hints', () => {
|
||||
it('derives selection ids from rendered entries while skipping headers', () => {
|
||||
expect(
|
||||
getWorktreePaletteSelectionItemIds([
|
||||
{ id: '__header_worktrees__', type: 'section-header' },
|
||||
@@ -198,6 +198,7 @@ describe('worktree-palette-create-action', () => {
|
||||
).toEqual([
|
||||
'worktree:one',
|
||||
CREATE_WORKTREE_ITEM_ID,
|
||||
'__hint_worktree_cap__',
|
||||
'settings:ai-provider-accounts',
|
||||
'quick-action:new-terminal',
|
||||
'browser-page:one'
|
||||
|
||||
@@ -66,7 +66,8 @@ const SELECTABLE_ENTRY_TYPES = [
|
||||
'browser-page',
|
||||
'workspace-tab',
|
||||
'simulator-tab',
|
||||
'project-target'
|
||||
'project-target',
|
||||
'hint'
|
||||
] as const
|
||||
|
||||
type WorktreePaletteSelectableEntryType = (typeof SELECTABLE_ENTRY_TYPES)[number]
|
||||
@@ -85,7 +86,7 @@ export function getWorktreePaletteSelectionItemIds<
|
||||
T extends WorktreePaletteSelectionCandidateEntry
|
||||
>(entries: readonly T[], renderKeys: readonly string[] = []): string[] {
|
||||
// Why: keyboard focus should mirror rendered order, including synthetic
|
||||
// action rows, while skipping headers and explanatory hint rows.
|
||||
// action rows, while skipping only section headers.
|
||||
// Why renderKeys wins: rows render under de-duplicated keys, so naming the bare
|
||||
// id here would leave a duplicate row absent from the list the `includes` check
|
||||
// above consults — arrowing onto it would snap the highlight back to the top.
|
||||
|
||||
Reference in New Issue
Block a user