diff --git a/src/renderer/src/components/ShortcutKeyCombo.tsx b/src/renderer/src/components/ShortcutKeyCombo.tsx index 1f231b9b7f5..8ec8c3a34bf 100644 --- a/src/renderer/src/components/ShortcutKeyCombo.tsx +++ b/src/renderer/src/components/ShortcutKeyCombo.tsx @@ -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 ( - + {label} ) @@ -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({ {keys.map((key, index) => ( - + {/* Why: Orca renders Mac shortcuts as adjacent glyphs, but Windows/Linux shortcuts read more naturally with explicit "+" separators. */} {!isMac && index < keys.length - 1 ? ( diff --git a/src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx b/src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx index 10e3ff8ed9c..ace7b22bc85 100644 --- a/src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx +++ b/src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx @@ -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( - + + + ) }) @@ -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( + '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', () => { diff --git a/src/renderer/src/components/sidebar/AddRepoStartSteps.tsx b/src/renderer/src/components/sidebar/AddRepoStartSteps.tsx index bfa8f5486e3..01e26e0b0df 100644 --- a/src/renderer/src/components/sidebar/AddRepoStartSteps.tsx +++ b/src/renderer/src/components/sidebar/AddRepoStartSteps.tsx @@ -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} > @@ -41,7 +48,11 @@ function AddRepoNestedScanProgressNotice({ - {translate("auto.components.sidebar.AddRepoStartSteps.d301db1c9a", "Scanning repositories. Click to stop.")} + {translate( + 'auto.components.sidebar.AddRepoStartSteps.d301db1c9a', + 'Scanning repositories. Click to stop.' + )} + ) : null} @@ -76,6 +87,7 @@ export function AddRepoLocalStartStep({ onStopNestedScan }: AddRepoLocalStartStepProps): React.JSX.Element { const browseActionRef = useRef(null) + const actionsRef = useRef(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(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): void => { + if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') { + return + } + const buttons = Array.from( + actionsRef.current?.querySelectorAll('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): void => { + if (!(event.relatedTarget instanceof HTMLButtonElement)) { + setSelectedKind(null) + return + } + if (!event.relatedTarget.matches('button[data-add-repo-action]')) { + setSelectedKind(null) + } + } + return ( <> - {translate("auto.components.sidebar.AddRepoStartSteps.d13757911c", "Add a project")} + + {translate('auto.components.sidebar.AddRepoStartSteps.d13757911c', 'Add a project')} + {repoCount === 0 ? ( - {translate("auto.components.sidebar.AddRepoStartSteps.acf895cb42", "Add a project to get started with Orca.")} + + {translate( + 'auto.components.sidebar.AddRepoStartSteps.acf895cb42', + 'Add a project to get started with Orca.' + )} + ) : null} -
+
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. */}
-

{translate("auto.components.sidebar.AddRepoStartSteps.f3c96237ae", "Or add from…")}

- {/* Match the primary card's surface (bg-background) so the group reads as the same family, not a recessed panel. */} -
+

+ {translate('auto.components.sidebar.AddRepoStartSteps.87596c1446', 'Other ways to add')} +

+ {/* 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. */} +
{secondaryActions.map((action, index) => ( 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 } +// Shared trailing chip so the ⏎ glyph travels with the selected action across primary and secondary rows. +const AddRepoEnterChip = (): React.JSX.Element => ( + +) + 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. ) @@ -184,26 +290,54 @@ function AddRepoSecondaryStartAction({ title, description, disabled, + selected, onClick, + onFocus, className }: AddRepoStartActionProps & { className?: string }): React.JSX.Element { return ( ) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 3d82c034bd5..c81955e74e8 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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.",