Fix add project action selection accessibility

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-06-09 15:43:21 -07:00
co-authored by Orca
parent 9f11b2d96f
commit cd6b0aef7e
2 changed files with 109 additions and 21 deletions
@@ -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>
)
})
@@ -210,6 +222,62 @@ describe('AddRepoLocalStartStep', () => {
})
})
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)
@@ -99,9 +99,13 @@ export function AddRepoLocalStartStep({
// 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>(primaryAction.kind)
const [selectedKind, setSelectedKind] = useState<string | null>(primaryAction.kind)
useEffect(() => {
if (isAdding) {
setSelectedKind(null)
return
}
if (!isAdding) {
browseActionRef.current?.focus()
}
@@ -125,6 +129,16 @@ export function AddRepoLocalStartStep({
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>
@@ -141,7 +155,12 @@ export function AddRepoLocalStartStep({
) : null}
</DialogHeader>
<div className="space-y-3 pt-2" ref={actionsRef} onKeyDown={handleArrowNavigation}>
<div
className="space-y-3 pt-2"
ref={actionsRef}
onBlur={handleActionsBlur}
onKeyDown={handleArrowNavigation}
>
<AddRepoPrimaryStartAction
icon={primaryAction.icon}
title={primaryAction.title}
@@ -204,11 +223,12 @@ type AddRepoStartActionProps = {
// Shared trailing chip so the ⏎ glyph travels with the selected action across primary and secondary rows.
const AddRepoEnterChip = (): React.JSX.Element => (
<ShortcutKeyCombo
keys={['⏎']}
keyCapClassName="border-primary-foreground/20 bg-primary-foreground/10 text-primary-foreground/80"
className="shrink-0"
/>
<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 = ({