Add Project modal: roving keyboard selection (#5052)

* Make Add Project highlight a roving keyboard selection

The Add Project modal previously rendered Browse folder as a permanently
filled "primary" card with a static ⏎ chip. Turn that white fill + ⏎ chip
into a roving selection indicator driven by keyboard focus: Browse starts
selected (it is autofocused on open), and Tab or ↑/↓ move the highlight —
and the ⏎ chip — to whichever action is focused, so Enter's target is always
the highlighted row.

Also flatten the unselected Browse card's surface to bg-background so it
matches the secondary rows instead of showing the outline variant's lighter
tinted/shadowed surface.

ShortcutKeyCombo gains an optional keyCapClassName so the ⏎ chip can tint
itself for the filled surface.

Co-authored-by: Orca <help@stably.ai>

* Drop focus ring on selected Browse card so it stays borderless

The primary Browse card uses the Button component, whose base styles always
draw a focus-visible border + ring. Because Browse is autofocused-and-selected
on open, that ring rendered as a border on only the top card, unlike the
filled secondary rows. Suppress the ring on the selected state (the fill + ⏎
chip already indicate focus, since focus drives selection) and add a
transparent border to hold the box size steady across the outline↔default swap.

Co-authored-by: Orca <help@stably.ai>

* Fix add project action selection accessibility

Co-authored-by: Orca <help@stably.ai>

* Refine add project action outlines

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson
2026-06-09 16:03:01 -07:00
committed by GitHub
co-authored by Orca
parent de743891fe
commit 442c16a357
4 changed files with 299 additions and 43 deletions
@@ -1,9 +1,14 @@
import React from 'react'
import { cn } from '@/lib/utils'
function KeyCap({ label }: { label: string }): React.JSX.Element {
function KeyCap({ label, className }: { label: string; className?: string }): React.JSX.Element {
return (
<span className="inline-flex min-w-6 items-center justify-center rounded border border-border/80 bg-secondary/70 px-1.5 py-0.5 text-xs font-medium text-muted-foreground shadow-sm">
<span
className={cn(
'inline-flex min-w-6 items-center justify-center rounded border border-border/80 bg-secondary/70 px-1.5 py-0.5 text-xs font-medium text-muted-foreground shadow-sm',
className
)}
>
{label}
</span>
)
@@ -13,12 +18,15 @@ type ShortcutKeyComboProps = {
keys: string[]
className?: string
separatorClassName?: string
// Override cap colors when chips sit on a non-default surface (e.g. a filled primary card).
keyCapClassName?: string
}
export function ShortcutKeyCombo({
keys,
className,
separatorClassName
separatorClassName,
keyCapClassName
}: ShortcutKeyComboProps): React.JSX.Element {
const isMac = navigator.userAgent.includes('Mac')
@@ -26,7 +34,7 @@ export function ShortcutKeyCombo({
<span className={cn('inline-flex items-center gap-1', className)}>
{keys.map((key, index) => (
<React.Fragment key={`${key}-${index}`}>
<KeyCap label={key} />
<KeyCap label={key} className={keyCapClassName} />
{/* Why: Orca renders Mac shortcuts as adjacent glyphs, but Windows/Linux
shortcuts read more naturally with explicit "+" separators. */}
{!isMac && index < keys.length - 1 ? (
@@ -51,7 +51,17 @@ function renderServerPathStartStep(runtimeEnvironmentId: string | null): string
)
}
async function renderLocalStartStepDom(isSshLikely: boolean): Promise<{
type LocalStartStepDomOptions = {
isAdding?: boolean
addProjectBusyLabel?: string | null
nestedScanInProgress?: boolean
nestedScanId?: string | null
}
async function renderLocalStartStepDom(
isSshLikely: boolean,
options: LocalStartStepDomOptions = {}
): Promise<{
container: HTMLDivElement
root: Root
}> {
@@ -61,19 +71,21 @@ async function renderLocalStartStepDom(isSshLikely: boolean): Promise<{
await act(async () => {
root.render(
<AddRepoLocalStartStep
repoCount={1}
isSshLikely={isSshLikely}
isAdding={false}
addProjectBusyLabel={null}
nestedScanInProgress={false}
nestedScanId={null}
onBrowse={vi.fn()}
onOpenCloneStep={vi.fn()}
onOpenRemoteStep={vi.fn()}
onOpenCreateStep={vi.fn()}
onStopNestedScan={vi.fn()}
/>
<TooltipProvider>
<AddRepoLocalStartStep
repoCount={1}
isSshLikely={isSshLikely}
isAdding={options.isAdding ?? false}
addProjectBusyLabel={options.addProjectBusyLabel ?? null}
nestedScanInProgress={options.nestedScanInProgress ?? false}
nestedScanId={options.nestedScanId ?? null}
onBrowse={vi.fn()}
onOpenCloneStep={vi.fn()}
onOpenRemoteStep={vi.fn()}
onOpenCreateStep={vi.fn()}
onStopNestedScan={vi.fn()}
/>
</TooltipProvider>
)
})
@@ -120,7 +132,7 @@ describe('AddRepoLocalStartStep', () => {
expect(markup).toContain('Clone from URL')
expect(markup).toContain('Remote project')
expect(markup).toContain('Create new project')
expect(markup).toContain('Or add from')
expect(markup).toContain('Other ways to add')
expect(markup).not.toContain('More options')
})
@@ -182,6 +194,108 @@ describe('AddRepoLocalStartStep', () => {
root.unmount()
})
})
it('marks the autofocused Browse action as selected with the ⏎ chip', async () => {
const { container, root } = await renderLocalStartStepDom(false)
expect(findButton(container, 'Browse folder').textContent).toContain('⏎')
expect(findButton(container, 'Clone from URL').textContent).not.toContain('⏎')
await act(async () => {
root.unmount()
})
})
it('moves the ⏎ selection to whichever action receives focus', async () => {
const { container, root } = await renderLocalStartStepDom(false)
const cloneButton = findButton(container, 'Clone from URL')
await act(async () => {
cloneButton.focus()
})
expect(findButton(container, 'Clone from URL').textContent).toContain('⏎')
expect(findButton(container, 'Browse folder').textContent).not.toContain('⏎')
await act(async () => {
root.unmount()
})
})
it('clears the ⏎ selection when focus leaves the action list', async () => {
const { container, root } = await renderLocalStartStepDom(false)
const outsideButton = document.createElement('button')
document.body.appendChild(outsideButton)
await act(async () => {
outsideButton.focus()
})
expect(document.activeElement).toBe(outsideButton)
expect(findButton(container, 'Browse folder').textContent).not.toContain('⏎')
expect(findButton(container, 'Clone from URL').textContent).not.toContain('⏎')
await act(async () => {
root.unmount()
})
})
it('does not show an ⏎ selection while add actions are busy', async () => {
const { container, root } = await renderLocalStartStepDom(false, {
isAdding: true,
addProjectBusyLabel: 'Scanning repositories',
nestedScanInProgress: true,
nestedScanId: 'scan-1'
})
const stopScanButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Stop scan"]'
)
await act(async () => {
stopScanButton?.focus()
})
expect(document.activeElement).toBe(stopScanButton)
expect(findButton(container, 'Browse folder').textContent).not.toContain('⏎')
expect(findButton(container, 'Clone from URL').textContent).not.toContain('⏎')
await act(async () => {
root.unmount()
})
})
it('hides the visual ⏎ chip from assistive technology', async () => {
const { container, root } = await renderLocalStartStepDom(false)
const browseButton = findButton(container, 'Browse folder')
const enterChip = Array.from(browseButton.querySelectorAll('[aria-hidden="true"]')).find(
(entry) => entry.textContent?.includes('⏎')
)
expect(enterChip).toBeTruthy()
await act(async () => {
root.unmount()
})
})
it('roves selection down the action list with the ArrowDown key', async () => {
const { container, root } = await renderLocalStartStepDom(false)
await act(async () => {
findButton(container, 'Browse folder').dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
)
})
// ArrowDown from Browse moves focus — and the ⏎ chip — to the first secondary action.
const firstSecondary = findButton(container, 'Clone from URL')
expect(document.activeElement).toBe(firstSecondary)
expect(firstSecondary.textContent).toContain('⏎')
await act(async () => {
root.unmount()
})
})
})
describe('AddRepoServerPathStartStep', () => {
@@ -1,7 +1,8 @@
import { useEffect, useRef, type ComponentType, type Ref } from 'react'
import { useEffect, useRef, useState, type ComponentType, type Ref } from 'react'
import { CircleStop, Loader2 } from 'lucide-react'
import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { getAddRepoLocalStartActions } from './add-repo-local-start-actions'
@@ -32,8 +33,14 @@ function AddRepoNestedScanProgressNotice({
variant="ghost"
size="icon-xs"
className="group text-muted-foreground hover:bg-destructive/10 hover:text-destructive focus-visible:bg-destructive/10 focus-visible:text-destructive focus-visible:ring-destructive/40"
aria-label={translate("auto.components.sidebar.AddRepoStartSteps.9906cae183", "Stop scan")}
title={translate("auto.components.sidebar.AddRepoStartSteps.69ea7f8dc4", "Stop scanning")}
aria-label={translate(
'auto.components.sidebar.AddRepoStartSteps.9906cae183',
'Stop scan'
)}
title={translate(
'auto.components.sidebar.AddRepoStartSteps.69ea7f8dc4',
'Stop scanning'
)}
onClick={onStopNestedScan}
>
<Loader2 className="size-3.5 animate-spin text-annotation-highlight group-hover:hidden group-focus-visible:hidden" />
@@ -41,7 +48,11 @@ function AddRepoNestedScanProgressNotice({
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate("auto.components.sidebar.AddRepoStartSteps.d301db1c9a", "Scanning repositories. Click to stop.")}</TooltipContent>
{translate(
'auto.components.sidebar.AddRepoStartSteps.d301db1c9a',
'Scanning repositories. Click to stop.'
)}
</TooltipContent>
</Tooltip>
) : null}
</div>
@@ -76,6 +87,7 @@ export function AddRepoLocalStartStep({
onStopNestedScan
}: AddRepoLocalStartStepProps): React.JSX.Element {
const browseActionRef = useRef<HTMLButtonElement | null>(null)
const actionsRef = useRef<HTMLDivElement | null>(null)
const { primaryAction, secondaryActions } = getAddRepoLocalStartActions({
isSshLikely,
onBrowse,
@@ -84,37 +96,91 @@ export function AddRepoLocalStartStep({
onOpenCreateStep
})
// The white fill + ⏎ chip is a roving selection indicator, not a fixed "primary" badge:
// it follows keyboard focus so Enter always activates the highlighted action. Browse is
// autofocused on open, so it starts selected; Tab and ↑/↓ move the highlight.
const [selectedKind, setSelectedKind] = useState<string | null>(primaryAction.kind)
useEffect(() => {
if (isAdding) {
setSelectedKind(null)
return
}
if (!isAdding) {
browseActionRef.current?.focus()
}
}, [isAdding])
// ↑/↓ rove focus across the action buttons in visual order; focus drives the selection.
const handleArrowNavigation = (event: React.KeyboardEvent<HTMLDivElement>): void => {
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') {
return
}
const buttons = Array.from(
actionsRef.current?.querySelectorAll<HTMLButtonElement>('button[data-add-repo-action]') ?? []
)
if (buttons.length === 0) {
return
}
const currentIndex = buttons.findIndex((button) => button === document.activeElement)
const delta = event.key === 'ArrowDown' ? 1 : -1
const nextIndex = (currentIndex + delta + buttons.length) % buttons.length
event.preventDefault()
buttons[nextIndex]?.focus()
}
const handleActionsBlur = (event: React.FocusEvent<HTMLDivElement>): void => {
if (!(event.relatedTarget instanceof HTMLButtonElement)) {
setSelectedKind(null)
return
}
if (!event.relatedTarget.matches('button[data-add-repo-action]')) {
setSelectedKind(null)
}
}
return (
<>
<DialogHeader>
<DialogTitle>{translate("auto.components.sidebar.AddRepoStartSteps.d13757911c", "Add a project")}</DialogTitle>
<DialogTitle>
{translate('auto.components.sidebar.AddRepoStartSteps.d13757911c', 'Add a project')}
</DialogTitle>
{repoCount === 0 ? (
<DialogDescription>{translate("auto.components.sidebar.AddRepoStartSteps.acf895cb42", "Add a project to get started with Orca.")}</DialogDescription>
<DialogDescription>
{translate(
'auto.components.sidebar.AddRepoStartSteps.acf895cb42',
'Add a project to get started with Orca.'
)}
</DialogDescription>
) : null}
</DialogHeader>
<div className="space-y-3 pt-2">
<div
className="space-y-3 pt-2"
ref={actionsRef}
onBlur={handleActionsBlur}
onKeyDown={handleArrowNavigation}
>
<AddRepoPrimaryStartAction
icon={primaryAction.icon}
title={primaryAction.title}
description={primaryAction.description}
disabled={isAdding}
selected={selectedKind === primaryAction.kind}
buttonRef={browseActionRef}
onClick={primaryAction.onClick}
onFocus={() => setSelectedKind(primaryAction.kind)}
/>
{/* Keep secondary entry methods always visible so they stay discoverable without an extra click. */}
{/* Label clarifies the lighter-weight rows are alternate entry methods, not lesser features. */}
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">{translate("auto.components.sidebar.AddRepoStartSteps.f3c96237ae", "Or add from…")}</p>
{/* Match the primary card's surface (bg-background) so the group reads as the same family, not a recessed panel. */}
<div className="overflow-hidden rounded-md border border-border/80 bg-background">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{translate('auto.components.sidebar.AddRepoStartSteps.87596c1446', 'Other ways to add')}
</p>
{/* Outline uses the `input` token (white-ish in dark mode) to match Browse's visible outline variant;
primary-foreground is near-black in dark mode and rendered the border invisible. */}
<div className="overflow-hidden rounded-md border border-input bg-background">
{secondaryActions.map((action, index) => (
<AddRepoSecondaryStartAction
key={action.kind}
@@ -122,7 +188,9 @@ export function AddRepoLocalStartStep({
title={action.title}
description={action.description}
disabled={isAdding}
selected={selectedKind === action.kind}
onClick={action.onClick}
onFocus={() => setSelectedKind(action.kind)}
className={index === 0 ? '' : 'border-t border-border/70'}
/>
))}
@@ -147,35 +215,73 @@ type AddRepoStartActionProps = {
title: string
description: string
disabled: boolean
// Selected = keyboard-focused: renders the white fill + trailing ⏎ chip so Enter's target is obvious.
selected: boolean
onClick: () => void
onFocus: () => void
buttonRef?: Ref<HTMLButtonElement>
}
// Shared trailing chip so the ⏎ glyph travels with the selected action across primary and secondary rows.
const AddRepoEnterChip = (): React.JSX.Element => (
<span aria-hidden="true" className="shrink-0">
<ShortcutKeyCombo
keys={['⏎']}
keyCapClassName="border-primary-foreground/20 bg-primary-foreground/10 text-primary-foreground/80"
/>
</span>
)
const AddRepoPrimaryStartAction = ({
icon: Icon,
title,
description,
disabled,
selected,
onClick,
onFocus,
buttonRef
}: AddRepoStartActionProps): React.JSX.Element => (
// Filled white surface marks the selected action; when unselected the card reverts to a quiet
// outline so the highlight reads as a moving selection, not a fixed badge. Inner tints switch to
// primary-foreground only while filled, because muted tokens are tuned for the dark base surface.
<Button
ref={buttonRef}
type="button"
variant="outline"
variant={selected ? 'default' : 'outline'}
onClick={onClick}
onFocus={onFocus}
disabled={disabled}
className="h-auto min-h-24 w-full justify-start gap-4 whitespace-normal border-border/80 bg-background px-4 py-4 text-left"
data-add-repo-action
className={cn(
'h-auto min-h-[3.75rem] w-full justify-start gap-3 whitespace-normal px-3 py-2.5 text-left',
// The subtle selected border keeps Browse and secondary rows feeling like one roving set.
// Focus ring stays off because the fill + ⏎ chip already mark the focused action.
selected
? 'border border-primary-foreground/20 focus-visible:border-primary-foreground/30 focus-visible:ring-0'
: 'bg-background shadow-none dark:bg-background'
)}
>
<span className="grid size-11 shrink-0 place-items-center rounded-md text-foreground">
<Icon className="size-5" />
<span
className={cn(
'grid size-7 shrink-0 place-items-center rounded-md',
selected ? 'bg-primary-foreground/10 text-primary-foreground' : 'text-foreground'
)}
>
<Icon className="size-4" />
</span>
<span className="min-w-0">
<span className="block text-sm font-semibold leading-5">{title}</span>
<span className="mt-0.5 block text-xs font-normal leading-5 text-muted-foreground">
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium leading-5">{title}</span>
<span
className={cn(
'mt-0.5 block text-xs font-normal leading-5',
selected ? 'text-primary-foreground/70' : 'text-muted-foreground'
)}
>
{description}
</span>
</span>
{selected ? <AddRepoEnterChip /> : null}
</Button>
)
@@ -184,26 +290,54 @@ function AddRepoSecondaryStartAction({
title,
description,
disabled,
selected,
onClick,
onFocus,
className
}: AddRepoStartActionProps & { className?: string }): React.JSX.Element {
return (
<button
type="button"
data-add-repo-action
disabled={disabled}
onClick={onClick}
onFocus={onFocus}
className={cn(
'flex min-h-[3.25rem] w-full items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-accent focus-visible:bg-accent focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-default disabled:opacity-40',
className
'flex min-h-[3.25rem] w-full items-center gap-3 border border-transparent px-3 py-2.5 text-left transition-colors focus-visible:outline-none disabled:pointer-events-none disabled:cursor-default disabled:opacity-40',
className,
// Selected mirrors the primary card's filled white surface so the highlight moves between rows.
selected
? 'border-primary-foreground/30 bg-primary text-primary-foreground focus-visible:border-primary-foreground/40'
: 'hover:bg-accent focus-visible:bg-accent focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50'
)}
>
<span className="grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground">
<span
className={cn(
'grid size-7 shrink-0 place-items-center rounded-md',
selected ? 'bg-primary-foreground/10 text-primary-foreground' : 'text-muted-foreground'
)}
>
<Icon className="size-4" />
</span>
<span className="min-w-0">
<span className="block text-sm font-medium leading-5 text-foreground">{title}</span>
<span className="block text-xs leading-4 text-muted-foreground">{description}</span>
<span className="min-w-0 flex-1">
<span
className={cn(
'block text-sm font-medium leading-5',
selected ? 'text-primary-foreground' : 'text-foreground'
)}
>
{title}
</span>
<span
className={cn(
'block text-xs leading-4',
selected ? 'text-primary-foreground/70' : 'text-muted-foreground'
)}
>
{description}
</span>
</span>
{selected ? <AddRepoEnterChip /> : null}
</button>
)
}
+1 -1
View File
@@ -3030,7 +3030,7 @@
"0f8aba944c": "Navigate to a directory and click Select to choose it."
},
"AddRepoStartSteps": {
"f3c96237ae": "Or add from…",
"87596c1446": "Other ways to add",
"acf895cb42": "Add a project to get started with Orca.",
"d13757911c": "Add a project",
"d301db1c9a": "Scanning repositories. Click to stop.",