Automations ux improvement (#17626)

* Add keyboard navigation to automations UI

Improves workflow efficiency by enabling keyboard-driven navigation
across automations list, run history, and detail pane tabs.

* Add Escape key support to automations detail pane

Pressing Escape now clears external and automation run page views,
then returns to the automations list. Also improves cross-browser
compatibility of keyboard event handling by using Element checks and
getAttribute instead of dataset access.

* Fix keyboard navigation to let Enter key reach focused controls

- Enter key now passes through to focused buttons, links, and other interactive controls
- Arrow key navigation through automation run history still works
- Prevents intercepting native keyboard behavior of interactive elements

* improve test

* Move keyboard focus to follow row selection

When navigating automation runs with arrow keys, focus must follow the selection so Enter key acts on the newly selected row rather than the previously focused one.
This commit is contained in:
Jinjing
2026-08-31 18:37:19 -07:00
committed by GitHub
parent 50938b2dbd
commit d2aab68ae7
18 changed files with 1392 additions and 40 deletions
@@ -250,7 +250,9 @@ describe('install-electron-package-binary', () => {
expect(result.status, result.stderr).toBe(0)
expect(existsSync(join(cacheRoot, 'preserved.marker'))).toBe(true)
expect(readFileSync(join(projectDir, 'electron-get.log'), 'utf8').trim().split('\n')).toHaveLength(2)
expect(
readFileSync(join(projectDir, 'electron-get.log'), 'utf8').trim().split('\n')
).toHaveLength(2)
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
@@ -361,7 +361,9 @@ describe('registerPtyHandlers', () => {
const [, , options] = spawnMock.mock.calls[0]!
expect(options.env.ORCA_SHELL_FEATURES).toContain('ready')
expect(options.env[POSIX_SHELL_STARTUP_COMMAND_ENV]).toBe("codex --prefill 'linked issue context'")
expect(options.env[POSIX_SHELL_STARTUP_COMMAND_ENV]).toBe(
"codex --prefill 'linked issue context'"
)
expect(mockProc.proc.write).not.toHaveBeenCalled()
mockProc.emitData('\x1b]777;orca-shell-ready\x07')
@@ -217,9 +217,7 @@ describe('registerPtyHandlers', () => {
expect(spawned.agentResumeUnavailable).toBeUndefined()
const env = spawnMock.mock.calls.at(-1)![2].env as Record<string, string>
expect(env.CODEX_HOME).toBe(ORIGIN_HOME)
expect(env[POSIX_SHELL_STARTUP_COMMAND_ENV]).toBe(
`codex 'resume' '${RESUME_SESSION_ID}'`
)
expect(env[POSIX_SHELL_STARTUP_COMMAND_ENV]).toBe(`codex 'resume' '${RESUME_SESSION_ID}'`)
expect(selectedHome).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
@@ -80,4 +80,37 @@ describe('AutomationListSearchField', () => {
expect(escape.defaultPrevented).toBe(true)
expect(onClear).toHaveBeenCalledTimes(1)
})
it('routes Enter into onEnter and ignores modified or composing Enter', () => {
const onEnter = vi.fn()
act(() => {
root.render(
<AutomationListSearchField
query="nightly"
isTooLarge={false}
onQueryChange={() => undefined}
onClear={() => undefined}
onEnter={onEnter}
/>
)
})
const input = container.querySelector('input')
expect(input).not.toBeNull()
const enter = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
input?.dispatchEvent(enter)
expect(enter.defaultPrevented).toBe(true)
expect(onEnter).toHaveBeenCalledTimes(1)
const shiftEnter = new KeyboardEvent('keydown', {
key: 'Enter',
shiftKey: true,
bubbles: true,
cancelable: true
})
input?.dispatchEvent(shiftEnter)
expect(shiftEnter.defaultPrevented).toBe(false)
expect(onEnter).toHaveBeenCalledTimes(1)
})
})
@@ -7,6 +7,7 @@ import { cn } from '@/lib/utils'
import {
isAutomationListArrowKey,
shouldHandleAutomationListSearchArrowKey,
shouldHandleAutomationListSearchEnterKey,
type AutomationListArrowKey
} from './automation-list-keyboard-navigation'
@@ -16,6 +17,7 @@ type AutomationListSearchFieldProps = {
onQueryChange: (query: string) => void
onClear: () => void
onArrowNavigate?: (key: AutomationListArrowKey) => void
onEnter?: () => void
className?: string
}
@@ -25,6 +27,7 @@ export function AutomationListSearchField({
onQueryChange,
onClear,
onArrowNavigate,
onEnter,
className
}: AutomationListSearchFieldProps): React.JSX.Element {
const inputRef = useRef<HTMLInputElement>(null)
@@ -74,6 +77,11 @@ export function AutomationListSearchField({
onArrowNavigate(event.key)
return
}
if (onEnter && shouldHandleAutomationListSearchEnterKey(event)) {
event.preventDefault()
onEnter()
return
}
if (event.key !== 'Escape' || event.nativeEvent.isComposing) {
return
}
@@ -131,3 +131,109 @@ describe('AutomationRunHistory unanswered history', () => {
expect(onRecoverHistory).toHaveBeenCalledWith('reconnect')
})
})
describe('AutomationRunHistory keyboard navigation', () => {
it('navigates runs with ArrowDown and ArrowUp and opens on Enter', async () => {
const onOpenRun = vi.fn()
const run1 = makeRun({ id: 'run-1', scheduledFor: FIRST })
const run2 = makeRun({ id: 'run-2', scheduledFor: LATEST })
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
roots.push(root)
await act(async () => {
root.render(
<AutomationRunHistory
runs={[run1, run2]}
automationId="a-1"
worktreeMap={new Map()}
onOpenRun={onOpenRun}
/>
)
})
const buttons = container.querySelectorAll<HTMLButtonElement>('button[data-automation-run-id]')
expect(buttons[0].getAttribute('data-current')).toBe('true')
expect(buttons[1].getAttribute('data-current')).toBe('false')
// Press ArrowDown
await act(async () => {
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true })
)
})
expect(buttons[0].getAttribute('data-current')).toBe('false')
expect(buttons[1].getAttribute('data-current')).toBe('true')
// Press Enter to open selected run
await act(async () => {
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
)
})
expect(onOpenRun).toHaveBeenCalledWith(run2)
// Press ArrowUp
await act(async () => {
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true, cancelable: true })
)
})
expect(buttons[0].getAttribute('data-current')).toBe('true')
expect(buttons[1].getAttribute('data-current')).toBe('false')
// Press Enter to open first run
await act(async () => {
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
)
})
expect(onOpenRun).toHaveBeenCalledWith(run1)
})
it('moves focus with the selection so Enter reaches the selected row, not the old one', async () => {
const onOpenRun = vi.fn()
const run1 = makeRun({ id: 'run-1', scheduledFor: FIRST })
const run2 = makeRun({ id: 'run-2', scheduledFor: LATEST })
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
roots.push(root)
await act(async () => {
root.render(
<AutomationRunHistory
runs={[run1, run2]}
automationId="a-1"
worktreeMap={new Map()}
onOpenRun={onOpenRun}
/>
)
})
const buttons = container.querySelectorAll<HTMLButtonElement>('button[data-automation-run-id]')
buttons[0].focus()
expect(document.activeElement).toBe(buttons[0])
await act(async () => {
buttons[0].dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true })
)
})
expect(buttons[1].getAttribute('data-current')).toBe('true')
expect(document.activeElement).toBe(buttons[1])
// Enter is passed through to the focused row, which must now be the selected one.
;(document.activeElement as HTMLButtonElement).click()
expect(onOpenRun).toHaveBeenCalledTimes(1)
expect(onOpenRun).toHaveBeenCalledWith(run2)
})
})
@@ -18,6 +18,11 @@ import { getAutomationRunWorkspaceDisplay } from './automation-run-workspace-dis
import { AutomationOwnerConflictNotice } from './AutomationOwnerConflictNotice'
import type { AutomationActionNotice } from './automation-row-action-dispatch'
import type { AutomationHostRecoveryAction } from './automation-host-status-descriptors'
import {
getAutomationRunHistoryArrowTarget,
isAutomationRunHistoryArrowKey,
shouldHandleAutomationRunHistoryKey
} from './automation-run-history-keyboard-navigation'
import { translate } from '@/i18n/i18n'
type AutomationRunHistoryProps = {
@@ -38,6 +43,7 @@ export function AutomationRunHistory({
onRecoverHistory,
onOpenRun
}: AutomationRunHistoryProps): React.JSX.Element {
const containerRef = React.useRef<HTMLDivElement>(null)
const [selectedRunState, setSelectedRunState] = useState<{
automationId: string
runId: string | null
@@ -54,8 +60,62 @@ export function AutomationRunHistory({
selectedRunState.automationId === automationId ? selectedRunState.runId : null
const selectedRun = runs.find((run) => run.id === selectedRunId) ?? runs[0] ?? null
const findRunRow = React.useCallback(
(runId: string): HTMLElement | null =>
containerRef.current?.querySelector<HTMLElement>(`[data-automation-run-id="${runId}"]`) ??
null,
[]
)
React.useEffect(() => {
if (runs.length === 0 || notice) {
return
}
const handleKeyDown = (event: KeyboardEvent): void => {
if (!shouldHandleAutomationRunHistoryKey(event)) {
return
}
if (event.key === 'Enter') {
if (selectedRun) {
event.preventDefault()
onOpenRun(selectedRun)
}
return
}
if (isAutomationRunHistoryArrowKey(event.key)) {
const targetRun = getAutomationRunHistoryArrowTarget({
runs,
selectedRunId: selectedRun?.id ?? null,
key: event.key
})
if (targetRun) {
event.preventDefault()
setSelectedRunState({ automationId, runId: targetRun.id })
// Enter is left to the focused control, so focus has to follow the selection.
findRunRow(targetRun.id)?.focus?.({ preventScroll: true })
}
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [automationId, findRunRow, notice, onOpenRun, runs, selectedRun])
React.useEffect(() => {
if (!selectedRunId) {
return
}
const element = findRunRow(selectedRunId)
if (element && typeof element.scrollIntoView === 'function') {
element.scrollIntoView({ block: 'nearest' })
}
}, [findRunRow, selectedRunId])
return (
<div className="rounded-md border border-border/50 bg-muted/20 shadow-sm">
<div ref={containerRef} className="rounded-md border border-border/50 bg-muted/20 shadow-sm">
<div className="flex items-center justify-between border-b border-border/50 px-3 py-2">
<div className="text-sm font-medium">
{translate('auto.components.automations.AutomationRunHistory.53fc5f07ab', 'Run history')}
@@ -94,6 +154,7 @@ export function AutomationRunHistory({
<button
key={run.id}
type="button"
data-automation-run-id={run.id}
data-current={selectedRun?.id === run.id}
className={cn(
'grid w-full grid-cols-[minmax(9rem,1fr)_minmax(10rem,1.1fr)_minmax(5rem,.55fr)_minmax(5rem,.55fr)_minmax(6rem,auto)] items-center gap-3 px-3 py-2 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
@@ -0,0 +1,229 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { AutomationsDetailPane } from './AutomationsDetailPane'
import { makeAutomation } from './automations-page-fixtures'
import type { AutomationPaneTab } from './automation-page-state'
let container: HTMLDivElement
let root: Root
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
})
function renderDetailPane(options: {
activePaneTab?: AutomationPaneTab
onActivePaneTabChange?: (tab: AutomationPaneTab) => void
selected?: ReturnType<typeof makeAutomation> | null
selectedExternal?: null
}) {
const selected =
options.selected !== undefined
? options.selected
: makeAutomation({
id: 'auto-1',
name: 'Nightly Sync',
prompt: 'Run sync'
})
const onActivePaneTabChange = options.onActivePaneTabChange ?? vi.fn()
const activePaneTab = options.activePaneTab ?? 'overview'
act(() => {
root.render(
<TooltipProvider>
<AutomationsDetailPane
selected={selected}
selectedExternal={null}
selectedExternalRunPage={null}
selectedAutomationRunPage={null}
selectedRuns={[]}
selectedRunsNotice={null}
activePaneTab={activePaneTab}
relativeNow={1000}
externalActionKey={null}
selectedRepoDisplayName="orca"
selectedRepoDefaultBaseRef="main"
selectedWorkspaceName="default"
selectedHostEntry={null}
hostLabelById={new Map()}
selectedRunNowAvailability={null}
selectedAutomationRunPageWorkspaceDisplay={null}
selectedAutomationRunPageViewState={null}
canRerunSelectedAutomationRunPage={false}
isSelectedAutomationRunPageRerunPending={false}
worktreeMap={new Map()}
fetchExternalAutomationRuns={async () => []}
onActivePaneTabChange={onActivePaneTabChange}
onClearExternalRunPage={() => undefined}
onClearAutomationRunPage={() => undefined}
requestExternalAction={() => undefined}
openExternalRunPage={() => undefined}
openEditExternalDialog={() => undefined}
runNow={() => undefined}
openEditDialog={() => undefined}
toggleAutomation={() => undefined}
requestDeleteAutomation={() => undefined}
rerunAutomationRun={() => undefined}
openRunWorkspace={() => undefined}
openAutomationRunPage={() => undefined}
onBackToList={() => undefined}
recoverSelectedRuns={() => undefined}
/>
</TooltipProvider>
)
})
return { onActivePaneTabChange }
}
describe('AutomationsDetailPane tab keyboard navigation', () => {
it('switches from overview to runs tab on ArrowRight', () => {
const onActivePaneTabChange = vi.fn()
renderDetailPane({ activePaneTab: 'overview', onActivePaneTabChange })
const rightArrow = new KeyboardEvent('keydown', {
key: 'ArrowRight',
bubbles: true,
cancelable: true
})
window.dispatchEvent(rightArrow)
expect(rightArrow.defaultPrevented).toBe(true)
expect(onActivePaneTabChange).toHaveBeenCalledWith('runs')
})
it('switches from runs to overview tab on ArrowLeft', () => {
const onActivePaneTabChange = vi.fn()
renderDetailPane({ activePaneTab: 'runs', onActivePaneTabChange })
const leftArrow = new KeyboardEvent('keydown', {
key: 'ArrowLeft',
bubbles: true,
cancelable: true
})
window.dispatchEvent(leftArrow)
expect(leftArrow.defaultPrevented).toBe(true)
expect(onActivePaneTabChange).toHaveBeenCalledWith('overview')
})
it('does nothing on ArrowLeft when already on overview', () => {
const onActivePaneTabChange = vi.fn()
renderDetailPane({ activePaneTab: 'overview', onActivePaneTabChange })
const leftArrow = new KeyboardEvent('keydown', {
key: 'ArrowLeft',
bubbles: true,
cancelable: true
})
window.dispatchEvent(leftArrow)
expect(leftArrow.defaultPrevented).toBe(false)
expect(onActivePaneTabChange).not.toHaveBeenCalled()
})
it('does nothing on ArrowRight when already on runs', () => {
const onActivePaneTabChange = vi.fn()
renderDetailPane({ activePaneTab: 'runs', onActivePaneTabChange })
const rightArrow = new KeyboardEvent('keydown', {
key: 'ArrowRight',
bubbles: true,
cancelable: true
})
window.dispatchEvent(rightArrow)
expect(rightArrow.defaultPrevented).toBe(false)
expect(onActivePaneTabChange).not.toHaveBeenCalled()
})
it('ignores arrow keys when focused inside an input element', () => {
const onActivePaneTabChange = vi.fn()
renderDetailPane({ activePaneTab: 'overview', onActivePaneTabChange })
const input = document.createElement('input')
container.appendChild(input)
input.focus()
const rightArrow = new KeyboardEvent('keydown', {
key: 'ArrowRight',
bubbles: true,
cancelable: true
})
input.dispatchEvent(rightArrow)
expect(onActivePaneTabChange).not.toHaveBeenCalled()
})
it('calls onBackToList on Escape key press from detail view', () => {
const onBackToList = vi.fn()
const selected = makeAutomation({ id: 'auto-1' })
act(() => {
root.render(
<TooltipProvider>
<AutomationsDetailPane
selected={selected}
selectedExternal={null}
selectedExternalRunPage={null}
selectedAutomationRunPage={null}
selectedRuns={[]}
selectedRunsNotice={null}
activePaneTab="overview"
relativeNow={1000}
externalActionKey={null}
selectedRepoDisplayName="orca"
selectedRepoDefaultBaseRef="main"
selectedWorkspaceName="default"
selectedHostEntry={null}
hostLabelById={new Map()}
selectedRunNowAvailability={null}
selectedAutomationRunPageWorkspaceDisplay={null}
selectedAutomationRunPageViewState={null}
canRerunSelectedAutomationRunPage={false}
isSelectedAutomationRunPageRerunPending={false}
worktreeMap={new Map()}
fetchExternalAutomationRuns={async () => []}
onActivePaneTabChange={() => undefined}
onClearExternalRunPage={() => undefined}
onClearAutomationRunPage={() => undefined}
requestExternalAction={() => undefined}
openExternalRunPage={() => undefined}
openEditExternalDialog={() => undefined}
runNow={() => undefined}
openEditDialog={() => undefined}
toggleAutomation={() => undefined}
requestDeleteAutomation={() => undefined}
rerunAutomationRun={() => undefined}
openRunWorkspace={() => undefined}
openAutomationRunPage={() => undefined}
onBackToList={onBackToList}
recoverSelectedRuns={() => undefined}
/>
</TooltipProvider>
)
})
const escapeEvent = new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true
})
window.dispatchEvent(escapeEvent)
expect(escapeEvent.defaultPrevented).toBe(true)
expect(onBackToList).toHaveBeenCalledTimes(1)
})
})
@@ -41,6 +41,11 @@ import type { AutomationTargetAvailability } from './automation-target-availabil
import type { AutomationRunViewState } from './automation-run-view-state'
import type { AutomationRunWorkspaceDisplay } from './automation-run-workspace-display'
import type { AutomationPaneTab, SelectedExternalRunPage } from './automation-page-state'
import {
getAutomationDetailNextTab,
shouldHandleAutomationDetailEscapeKey,
shouldHandleAutomationDetailTabArrowKey
} from './automation-detail-tab-navigation'
import { translate } from '@/i18n/i18n'
type AutomationsDetailPaneProps = {
@@ -135,6 +140,53 @@ export function AutomationsDetailPane({
onBackToList,
recoverSelectedRuns
}: AutomationsDetailPaneProps): React.JSX.Element {
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent): void => {
if (shouldHandleAutomationDetailEscapeKey(event)) {
event.preventDefault()
if (selectedExternalRunPage) {
onClearExternalRunPage()
return
}
if (selectedAutomationRunPage) {
onClearAutomationRunPage()
return
}
onBackToList()
return
}
if (selectedExternal || !selected) {
return
}
if (shouldHandleAutomationDetailTabArrowKey(event)) {
const nextTab = getAutomationDetailNextTab({
currentTab: activePaneTab,
key: event.key as 'ArrowLeft' | 'ArrowRight',
canAccessRuns: Boolean(selected)
})
if (nextTab && nextTab !== activePaneTab) {
event.preventDefault()
onActivePaneTabChange(nextTab)
}
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [
activePaneTab,
onActivePaneTabChange,
onBackToList,
onClearAutomationRunPage,
onClearExternalRunPage,
selected,
selectedAutomationRunPage,
selectedExternal,
selectedExternalRunPage
])
return (
<section className="flex min-h-0 flex-1 flex-col overflow-hidden">
{selectedExternal ? (
@@ -13,8 +13,17 @@ import { TooltipProvider } from '@/components/ui/tooltip'
import { AutomationsListPanel } from './AutomationsListPanel'
import { EMPTY_AUTOMATION_LIST_FILTER } from './automation-list-view'
import type { AutomationHostCatalogView } from './use-automation-host-catalog'
import { makeAutomation, makeAutomationListRow } from './automations-page-fixtures'
import {
makeAutomation,
makeAutomationListRow,
makeScopedExternalManager
} from './automations-page-fixtures'
import type { AutomationListRow } from './automation-list-row-identity'
import {
buildExternalAutomationListEntries,
type ExternalAutomationListEntry
} from './external-automation-list-entries'
import type { AutomationPaneTab } from './automation-page-state'
let container: HTMLDivElement
let root: Root
@@ -52,22 +61,32 @@ function renderPanel(
rows: readonly AutomationListRow[],
query: string,
onQueryChange: (next: string) => void = () => undefined,
uncheckedNotice: string | null = null
uncheckedNotice: string | null = null,
options: {
selectedRowKey?: string | null
selectedExternalKey?: string | null
onOpenDetail?: () => void
selectAutomationRow?: (key: string | null) => void
selectExternalKey?: (key: string | null) => void
externalEntries?: readonly ExternalAutomationListEntry[]
setActivePaneTab?: (tab: AutomationPaneTab) => void
} = {}
): void {
const externalEntries = options.externalEntries ?? []
act(() => {
root.render(
<TooltipProvider>
<AutomationsListPanel
hasListItems={rows.length > 0}
hasFilteredListItems={rows.length > 0}
hasListItems={rows.length > 0 || externalEntries.length > 0}
hasFilteredListItems={rows.length > 0 || externalEntries.length > 0}
listFilter={EMPTY_AUTOMATION_LIST_FILTER}
onListFilterChange={() => undefined}
listSearchQuery={query}
isListSearchQueryTooLarge={false}
onListSearchQueryChange={onQueryChange}
searchCounts={{
hostRowCount: rows.length,
visibleRowCount: rows.length,
hostRowCount: rows.length + externalEntries.length,
visibleRowCount: rows.length + externalEntries.length,
searchActive: query !== ''
}}
hostCatalog={HOST_CATALOG}
@@ -76,9 +95,9 @@ function renderPanel(
onSelectHost={() => undefined}
onRecoverHost={() => undefined}
filteredRows={rows}
filteredExternalAutomationEntries={[]}
selectedRowKey={null}
selectedExternalKey={null}
filteredExternalAutomationEntries={externalEntries}
selectedRowKey={options.selectedRowKey ?? null}
selectedExternalKey={options.selectedExternalKey ?? null}
relativeNow={0}
repoMap={new Map()}
worktreeMap={new Map()}
@@ -89,9 +108,9 @@ function renderPanel(
automationSourceHostAvailabilityByRowKey={new Map()}
isActionEnabled={() => true}
externalActionKey={null}
selectAutomationRow={() => undefined}
selectExternalKey={() => undefined}
setActivePaneTab={() => undefined}
selectAutomationRow={options.selectAutomationRow ?? (() => undefined)}
selectExternalKey={options.selectExternalKey ?? (() => undefined)}
setActivePaneTab={options.setActivePaneTab ?? (() => undefined)}
runNow={() => undefined}
openEditDialog={() => undefined}
toggleAutomation={() => undefined}
@@ -99,7 +118,7 @@ function renderPanel(
requestExternalAction={() => undefined}
openEditExternalDialog={() => undefined}
openCreateDialog={() => undefined}
onOpenDetail={() => undefined}
onOpenDetail={options.onOpenDetail ?? (() => undefined)}
onRefresh={() => undefined}
isRefreshing={false}
/>
@@ -179,3 +198,118 @@ describe('AutomationsListPanel flat table layout', () => {
expect(container.textContent).toContain('Remote Linux')
})
})
describe('AutomationsListPanel enter key navigation', () => {
it('opens detail of the first row on Enter when nothing is selected', () => {
const row = makeAutomationListRow({
automation: makeAutomation({ id: 'auto-1', name: 'First Auto' })
})
let selectedKey: string | null = null
let detailOpened = false
renderPanel([row], '', () => undefined, null, {
selectedRowKey: null,
selectAutomationRow: (key) => {
selectedKey = key
},
onOpenDetail: () => {
detailOpened = true
}
})
const input = searchField()
expect(input).not.toBeNull()
const enter = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
input?.dispatchEvent(enter)
expect(enter.defaultPrevented).toBe(true)
expect(selectedKey).toBe(row.key)
expect(detailOpened).toBe(true)
})
it('opens detail of the selected row on Enter', () => {
const row1 = makeAutomationListRow({
automation: makeAutomation({ id: 'auto-1', name: 'First Auto' })
})
const row2 = makeAutomationListRow({
automation: makeAutomation({ id: 'auto-2', name: 'Second Auto' })
})
let selectedKey: string | null = null
let detailOpened = false
renderPanel([row1, row2], '', () => undefined, null, {
selectedRowKey: row2.key,
selectAutomationRow: (key) => {
selectedKey = key
},
onOpenDetail: () => {
detailOpened = true
}
})
const input = searchField()
expect(input).not.toBeNull()
const enter = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
input?.dispatchEvent(enter)
expect(enter.defaultPrevented).toBe(true)
expect(selectedKey).toBe(row2.key)
expect(detailOpened).toBe(true)
})
it('does nothing on Enter when there are no visible rows', () => {
let detailOpened = false
renderPanel([], '', () => undefined, null, {
onOpenDetail: () => {
detailOpened = true
}
})
const input = searchField()
expect(input).not.toBeNull()
const enter = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
input?.dispatchEvent(enter)
expect(detailOpened).toBe(false)
})
it('opens the selected external row on its overview tab', () => {
const [entry] = buildExternalAutomationListEntries([makeScopedExternalManager()])
expect(entry).toBeDefined()
if (!entry) {
return
}
const localSelections: (string | null)[] = []
const externalSelections: (string | null)[] = []
const paneTabs: AutomationPaneTab[] = []
let detailOpened = false
renderPanel([], '', () => undefined, null, {
externalEntries: [entry],
selectedExternalKey: entry.key,
selectAutomationRow: (key) => localSelections.push(key),
selectExternalKey: (key) => externalSelections.push(key),
setActivePaneTab: (tab) => paneTabs.push(tab),
onOpenDetail: () => {
detailOpened = true
}
})
const enter = new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true
})
searchField()?.dispatchEvent(enter)
expect(enter.defaultPrevented).toBe(true)
expect(localSelections).toEqual([null])
expect(externalSelections).toEqual([entry.key])
expect(paneTabs).toEqual(['overview'])
expect(detailOpened).toBe(true)
})
})
@@ -20,6 +20,7 @@ import type { AutomationRowAction } from './automation-captured-owner'
import type { AutomationHostTarget } from './automation-host-client'
import { clampAutomationListSearchQueryInput } from './automation-list-search'
import {
createAutomationListEnterHandler,
getAutomationListArrowNavigationTarget,
type AutomationListArrowKey
} from './automation-list-keyboard-navigation'
@@ -214,6 +215,15 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS
visibleItems
]
)
const handleSearchEnter = createAutomationListEnterHandler({
items: visibleItems,
selectedId: selectedRowKey,
selectedExternalKey,
selectAutomationRow,
selectExternalKey,
setActivePaneTab,
onOpenDetail
})
React.useEffect(() => {
if (!pendingKeyboardScrollRef.current) {
return
@@ -286,6 +296,7 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS
}
onClear={() => onListSearchQueryChange('')}
onArrowNavigate={handleSearchArrowNavigate}
onEnter={handleSearchEnter}
/>
<AutomationListFilterMenu
filter={listFilter}
@@ -2511,32 +2511,33 @@ export default function AutomationsPage(): React.JSX.Element {
}
const target = event.target
if (!(target instanceof HTMLElement)) {
return
}
// Why: popovers and menus live outside the store's modal registry; they own Esc too.
if (hasVisibleOverlay()) {
return
}
// Why: fields that clear their own value on Escape consume this press;
// blurring here would drop focus and let the next Escape close the page.
if (target.dataset.escapeClearsValue === 'true') {
return
}
if (target instanceof Element) {
// Why: fields that clear their own value on Escape consume this press;
// blurring here would drop focus and let the next Escape close the page.
if (target.getAttribute('data-escape-clears-value') === 'true') {
return
}
// Why: match Tasks page behavior: Esc first exits field focus, then exits
// the page once focus is back on page chrome.
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
target.isContentEditable
) {
event.preventDefault()
target.blur()
return
// Why: match Tasks page behavior: Esc first exits field focus, then exits
// the page once focus is back on page chrome.
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
(target instanceof HTMLElement && target.isContentEditable) ||
target.matches('[contenteditable="true"], [contenteditable=""]')
) {
event.preventDefault()
if (target instanceof HTMLElement) {
target.blur()
}
return
}
}
// Why: detail is a full-page drill-in; step out of nested run views first,
@@ -0,0 +1,207 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest'
import {
getAutomationDetailNextTab,
isAutomationDetailTabArrowKey,
shouldHandleAutomationDetailEscapeKey,
shouldHandleAutomationDetailTabArrowKey
} from './automation-detail-tab-navigation'
describe('isAutomationDetailTabArrowKey', () => {
it('identifies ArrowLeft and ArrowRight', () => {
expect(isAutomationDetailTabArrowKey('ArrowLeft')).toBe(true)
expect(isAutomationDetailTabArrowKey('ArrowRight')).toBe(true)
expect(isAutomationDetailTabArrowKey('ArrowUp')).toBe(false)
expect(isAutomationDetailTabArrowKey('ArrowDown')).toBe(false)
expect(isAutomationDetailTabArrowKey('Enter')).toBe(false)
})
})
describe('shouldHandleAutomationDetailTabArrowKey', () => {
function makeEvent(
overrides: Partial<{
key: string
altKey: boolean
ctrlKey: boolean
metaKey: boolean
shiftKey: boolean
isComposing: boolean
target: EventTarget | null
}> = {}
) {
return {
key: 'ArrowRight',
altKey: false,
ctrlKey: false,
metaKey: false,
shiftKey: false,
nativeEvent: { isComposing: overrides.isComposing ?? false },
target: overrides.target ?? document.body,
...overrides
}
}
it('allows unmodified ArrowLeft and ArrowRight on neutral targets', () => {
expect(shouldHandleAutomationDetailTabArrowKey(makeEvent({ key: 'ArrowRight' }))).toBe(true)
expect(shouldHandleAutomationDetailTabArrowKey(makeEvent({ key: 'ArrowLeft' }))).toBe(true)
})
it('ignores modified or composing arrow keys', () => {
expect(shouldHandleAutomationDetailTabArrowKey(makeEvent({ metaKey: true }))).toBe(false)
expect(shouldHandleAutomationDetailTabArrowKey(makeEvent({ ctrlKey: true }))).toBe(false)
expect(shouldHandleAutomationDetailTabArrowKey(makeEvent({ altKey: true }))).toBe(false)
expect(shouldHandleAutomationDetailTabArrowKey(makeEvent({ shiftKey: true }))).toBe(false)
expect(shouldHandleAutomationDetailTabArrowKey(makeEvent({ isComposing: true }))).toBe(false)
})
it('ignores keys when focus is inside text input, textarea, or contentEditable', () => {
const input = document.createElement('input')
const textarea = document.createElement('textarea')
const select = document.createElement('select')
const editable = document.createElement('div')
editable.contentEditable = 'true'
expect(shouldHandleAutomationDetailTabArrowKey(makeEvent({ target: input }))).toBe(false)
expect(shouldHandleAutomationDetailTabArrowKey(makeEvent({ target: textarea }))).toBe(false)
expect(shouldHandleAutomationDetailTabArrowKey(makeEvent({ target: select }))).toBe(false)
expect(shouldHandleAutomationDetailTabArrowKey(makeEvent({ target: editable }))).toBe(false)
})
it('ignores keys when target is inside a modal dialog, menu, or listbox', () => {
const dialog = document.createElement('div')
dialog.setAttribute('role', 'dialog')
const childButton = document.createElement('button')
dialog.appendChild(childButton)
document.body.appendChild(dialog)
expect(shouldHandleAutomationDetailTabArrowKey(makeEvent({ target: childButton }))).toBe(false)
dialog.remove()
})
})
describe('getAutomationDetailNextTab', () => {
it('switches from overview to runs on ArrowRight', () => {
expect(
getAutomationDetailNextTab({
currentTab: 'overview',
key: 'ArrowRight',
canAccessRuns: true
})
).toBe('runs')
})
it('does nothing when on overview and pressing ArrowLeft', () => {
expect(
getAutomationDetailNextTab({
currentTab: 'overview',
key: 'ArrowLeft'
})
).toBeNull()
})
it('switches from runs to overview on ArrowLeft', () => {
expect(
getAutomationDetailNextTab({
currentTab: 'runs',
key: 'ArrowLeft'
})
).toBe('overview')
})
it('does nothing when on runs and pressing ArrowRight', () => {
expect(
getAutomationDetailNextTab({
currentTab: 'runs',
key: 'ArrowRight'
})
).toBeNull()
})
it('prevents switching to runs if canAccessRuns is false', () => {
expect(
getAutomationDetailNextTab({
currentTab: 'overview',
key: 'ArrowRight',
canAccessRuns: false
})
).toBeNull()
})
})
describe('shouldHandleAutomationDetailEscapeKey', () => {
function makeEvent(
overrides: Partial<{
key: string
altKey: boolean
ctrlKey: boolean
metaKey: boolean
shiftKey: boolean
isComposing: boolean
target: EventTarget | null
}> = {}
) {
return {
key: 'Escape',
altKey: false,
ctrlKey: false,
metaKey: false,
shiftKey: false,
nativeEvent: { isComposing: overrides.isComposing ?? false },
target: overrides.target ?? document.body,
...overrides
}
}
it('allows unmodified Escape on neutral targets', () => {
expect(shouldHandleAutomationDetailEscapeKey(makeEvent())).toBe(true)
})
it('allows Escape on SVGElement and document targets', () => {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
expect(shouldHandleAutomationDetailEscapeKey(makeEvent({ target: svg }))).toBe(true)
expect(shouldHandleAutomationDetailEscapeKey(makeEvent({ target: document }))).toBe(true)
})
it('ignores non-Escape keys', () => {
expect(shouldHandleAutomationDetailEscapeKey(makeEvent({ key: 'Enter' }))).toBe(false)
expect(shouldHandleAutomationDetailEscapeKey(makeEvent({ key: 'ArrowLeft' }))).toBe(false)
})
it('ignores modified or composing Escape keys', () => {
expect(shouldHandleAutomationDetailEscapeKey(makeEvent({ metaKey: true }))).toBe(false)
expect(shouldHandleAutomationDetailEscapeKey(makeEvent({ ctrlKey: true }))).toBe(false)
expect(shouldHandleAutomationDetailEscapeKey(makeEvent({ altKey: true }))).toBe(false)
expect(shouldHandleAutomationDetailEscapeKey(makeEvent({ shiftKey: true }))).toBe(false)
expect(shouldHandleAutomationDetailEscapeKey(makeEvent({ isComposing: true }))).toBe(false)
})
it('ignores Escape when focus is inside text input, textarea, contentEditable, or escapeClearsValue', () => {
const input = document.createElement('input')
const textarea = document.createElement('textarea')
const select = document.createElement('select')
const editable = document.createElement('div')
editable.contentEditable = 'true'
const clearsValue = document.createElement('div')
clearsValue.setAttribute('data-escape-clears-value', 'true')
expect(shouldHandleAutomationDetailEscapeKey(makeEvent({ target: input }))).toBe(false)
expect(shouldHandleAutomationDetailEscapeKey(makeEvent({ target: textarea }))).toBe(false)
expect(shouldHandleAutomationDetailEscapeKey(makeEvent({ target: select }))).toBe(false)
expect(shouldHandleAutomationDetailEscapeKey(makeEvent({ target: editable }))).toBe(false)
expect(shouldHandleAutomationDetailEscapeKey(makeEvent({ target: clearsValue }))).toBe(false)
})
it('ignores Escape when target is inside a modal dialog, menu, or listbox', () => {
const dialog = document.createElement('div')
dialog.setAttribute('role', 'dialog')
const childButton = document.createElement('button')
dialog.appendChild(childButton)
document.body.appendChild(dialog)
expect(shouldHandleAutomationDetailEscapeKey(makeEvent({ target: childButton }))).toBe(false)
dialog.remove()
})
})
@@ -0,0 +1,109 @@
import type { AutomationPaneTab } from './automation-page-state'
export type AutomationDetailTabArrowKey = 'ArrowLeft' | 'ArrowRight'
export function isAutomationDetailTabArrowKey(key: string): key is AutomationDetailTabArrowKey {
return key === 'ArrowLeft' || key === 'ArrowRight'
}
export function shouldHandleAutomationDetailTabArrowKey(event: {
key: string
altKey: boolean
ctrlKey: boolean
metaKey: boolean
shiftKey: boolean
nativeEvent?: { isComposing?: boolean }
target?: EventTarget | null
}): boolean {
if (
!isAutomationDetailTabArrowKey(event.key) ||
Boolean(event.nativeEvent?.isComposing) ||
event.altKey ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey
) {
return false
}
const target = event.target
if (target instanceof Element) {
if (
(target instanceof HTMLElement && target.isContentEditable) ||
target.matches(
'input, textarea, select, [contenteditable="true"], [contenteditable=""], [role="textbox"]'
)
) {
return false
}
if (target.closest('[role="dialog"], [role="menu"], [role="listbox"]')) {
return false
}
}
return true
}
export function shouldHandleAutomationDetailEscapeKey(event: {
key: string
altKey: boolean
ctrlKey: boolean
metaKey: boolean
shiftKey: boolean
nativeEvent?: { isComposing?: boolean }
target?: EventTarget | null
}): boolean {
if (
event.key !== 'Escape' ||
Boolean(event.nativeEvent?.isComposing) ||
event.altKey ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey
) {
return false
}
const target = event.target
if (target instanceof Element) {
if (target.getAttribute('data-escape-clears-value') === 'true') {
return false
}
if (
(target instanceof HTMLElement && target.isContentEditable) ||
target.matches(
'input, textarea, select, [contenteditable="true"], [contenteditable=""], [role="textbox"]'
)
) {
return false
}
if (target.closest('[role="dialog"], [role="menu"], [role="listbox"]')) {
return false
}
}
return true
}
export function getAutomationDetailNextTab(args: {
currentTab: AutomationPaneTab
key: AutomationDetailTabArrowKey
canAccessRuns?: boolean
}): AutomationPaneTab | null {
const { currentTab, key, canAccessRuns = true } = args
if (key === 'ArrowRight') {
if (currentTab === 'overview' && canAccessRuns) {
return 'runs'
}
return null
}
if (key === 'ArrowLeft') {
if (currentTab === 'runs') {
return 'overview'
}
return null
}
return null
}
@@ -2,8 +2,10 @@ import { describe, expect, it } from 'vitest'
import {
findAutomationListSelectionIndex,
getAutomationListArrowNavigationTarget,
getAutomationListEnterNavigationTarget,
isAutomationListArrowKey,
shouldHandleAutomationListSearchArrowKey
shouldHandleAutomationListSearchArrowKey,
shouldHandleAutomationListSearchEnterKey
} from './automation-list-keyboard-navigation'
const items = [
@@ -150,3 +152,92 @@ describe('getAutomationListArrowNavigationTarget', () => {
).toEqual(items[2])
})
})
describe('shouldHandleAutomationListSearchEnterKey', () => {
function event(
overrides: Partial<{
key: string
altKey: boolean
ctrlKey: boolean
metaKey: boolean
shiftKey: boolean
isComposing: boolean
}> = {}
) {
return {
key: 'Enter',
altKey: false,
ctrlKey: false,
metaKey: false,
shiftKey: false,
nativeEvent: { isComposing: overrides.isComposing ?? false },
...overrides
}
}
it('handles plain Enter', () => {
expect(shouldHandleAutomationListSearchEnterKey(event())).toBe(true)
})
it('ignores composing, modified, and non-enter keys', () => {
expect(shouldHandleAutomationListSearchEnterKey(event({ isComposing: true }))).toBe(false)
expect(shouldHandleAutomationListSearchEnterKey(event({ metaKey: true }))).toBe(false)
expect(shouldHandleAutomationListSearchEnterKey(event({ ctrlKey: true }))).toBe(false)
expect(shouldHandleAutomationListSearchEnterKey(event({ altKey: true }))).toBe(false)
expect(shouldHandleAutomationListSearchEnterKey(event({ shiftKey: true }))).toBe(false)
expect(shouldHandleAutomationListSearchEnterKey(event({ key: 'ArrowDown' }))).toBe(false)
expect(shouldHandleAutomationListSearchEnterKey(event({ key: 'Escape' }))).toBe(false)
})
})
describe('getAutomationListEnterNavigationTarget', () => {
it('returns null when the list is empty', () => {
expect(
getAutomationListEnterNavigationTarget({
items: [],
selectedId: null,
selectedExternalKey: null
})
).toBeNull()
})
it('returns the first row when nothing is selected', () => {
expect(
getAutomationListEnterNavigationTarget({
items,
selectedId: null,
selectedExternalKey: null
})
).toEqual(items[0])
})
it('returns the first row when selection is not in visible items', () => {
expect(
getAutomationListEnterNavigationTarget({
items,
selectedId: 'missing-row',
selectedExternalKey: null
})
).toEqual(items[0])
})
it('returns the selected local row when selected', () => {
expect(
getAutomationListEnterNavigationTarget({
items,
selectedId: 'local-2',
selectedExternalKey: null
})
).toEqual(items[1])
})
it('returns the selected external row when selected', () => {
expect(
getAutomationListEnterNavigationTarget({
items,
selectedId: null,
selectedExternalKey: 'ext-1'
})
).toEqual(items[2])
})
})
@@ -1,4 +1,5 @@
import type { AutomationListViewItem } from './automation-list-view'
import type { AutomationPaneTab } from './automation-page-state'
export type AutomationListArrowKey = 'ArrowUp' | 'ArrowDown'
@@ -24,6 +25,24 @@ export function shouldHandleAutomationListSearchArrowKey(event: {
)
}
export function shouldHandleAutomationListSearchEnterKey(event: {
key: string
altKey: boolean
ctrlKey: boolean
metaKey: boolean
shiftKey: boolean
nativeEvent: { isComposing: boolean }
}): boolean {
return (
event.key === 'Enter' &&
!event.nativeEvent.isComposing &&
!event.altKey &&
!event.ctrlKey &&
!event.metaKey &&
!event.shiftKey
)
}
export function findAutomationListSelectionIndex(
items: readonly Pick<AutomationListViewItem, 'id' | 'kind'>[],
selectedId: string | null,
@@ -58,3 +77,49 @@ export function getAutomationListArrowNavigationTarget(args: {
}
return items[nextIndex] ?? null
}
export function getAutomationListEnterNavigationTarget(args: {
items: readonly Pick<AutomationListViewItem, 'id' | 'kind'>[]
selectedId: string | null
selectedExternalKey: string | null
}): Pick<AutomationListViewItem, 'id' | 'kind'> | null {
const { items, selectedId, selectedExternalKey } = args
if (items.length === 0) {
return null
}
const currentIndex = findAutomationListSelectionIndex(items, selectedId, selectedExternalKey)
if (currentIndex >= 0) {
return items[currentIndex] ?? null
}
return items[0] ?? null
}
export function activateAutomationListEnterTarget(args: {
items: readonly Pick<AutomationListViewItem, 'id' | 'kind'>[]
selectedId: string | null
selectedExternalKey: string | null
selectAutomationRow: (rowKey: string | null) => void
selectExternalKey: (externalKey: string | null) => void
setActivePaneTab: (tab: AutomationPaneTab) => void
onOpenDetail: () => void
}): void {
const target = getAutomationListEnterNavigationTarget(args)
if (!target) {
return
}
if (target.kind === 'local') {
args.selectExternalKey(null)
args.selectAutomationRow(target.id)
} else {
args.selectAutomationRow(null)
args.selectExternalKey(target.id)
args.setActivePaneTab('overview')
}
args.onOpenDetail()
}
export function createAutomationListEnterHandler(
args: Parameters<typeof activateAutomationListEnterTarget>[0]
): () => void {
return () => activateAutomationListEnterTarget(args)
}
@@ -0,0 +1,169 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest'
import { makeRun } from './automations-page-fixtures'
import {
getAutomationRunHistoryArrowTarget,
isAutomationRunHistoryArrowKey,
shouldHandleAutomationRunHistoryKey
} from './automation-run-history-keyboard-navigation'
describe('isAutomationRunHistoryArrowKey', () => {
it('identifies ArrowUp and ArrowDown', () => {
expect(isAutomationRunHistoryArrowKey('ArrowUp')).toBe(true)
expect(isAutomationRunHistoryArrowKey('ArrowDown')).toBe(true)
expect(isAutomationRunHistoryArrowKey('ArrowLeft')).toBe(false)
expect(isAutomationRunHistoryArrowKey('ArrowRight')).toBe(false)
expect(isAutomationRunHistoryArrowKey('Enter')).toBe(false)
})
})
describe('shouldHandleAutomationRunHistoryKey', () => {
function makeEvent(
overrides: Partial<{
key: string
altKey: boolean
ctrlKey: boolean
metaKey: boolean
shiftKey: boolean
isComposing: boolean
target: EventTarget | null
}> = {}
) {
return {
key: 'ArrowDown',
altKey: false,
ctrlKey: false,
metaKey: false,
shiftKey: false,
nativeEvent: { isComposing: overrides.isComposing ?? false },
target: overrides.target ?? document.body,
...overrides
}
}
it('allows unmodified ArrowUp, ArrowDown, and Enter', () => {
expect(shouldHandleAutomationRunHistoryKey(makeEvent({ key: 'ArrowDown' }))).toBe(true)
expect(shouldHandleAutomationRunHistoryKey(makeEvent({ key: 'ArrowUp' }))).toBe(true)
expect(shouldHandleAutomationRunHistoryKey(makeEvent({ key: 'Enter' }))).toBe(true)
})
it('ignores other keys', () => {
expect(shouldHandleAutomationRunHistoryKey(makeEvent({ key: 'ArrowLeft' }))).toBe(false)
expect(shouldHandleAutomationRunHistoryKey(makeEvent({ key: 'ArrowRight' }))).toBe(false)
expect(shouldHandleAutomationRunHistoryKey(makeEvent({ key: 'Space' }))).toBe(false)
})
it('ignores modified or composing keys', () => {
expect(shouldHandleAutomationRunHistoryKey(makeEvent({ metaKey: true }))).toBe(false)
expect(shouldHandleAutomationRunHistoryKey(makeEvent({ ctrlKey: true }))).toBe(false)
expect(shouldHandleAutomationRunHistoryKey(makeEvent({ altKey: true }))).toBe(false)
expect(shouldHandleAutomationRunHistoryKey(makeEvent({ shiftKey: true }))).toBe(false)
expect(shouldHandleAutomationRunHistoryKey(makeEvent({ isComposing: true }))).toBe(false)
})
it('ignores keys when target is an editable input element or inside a modal dialog', () => {
const input = document.createElement('input')
expect(shouldHandleAutomationRunHistoryKey(makeEvent({ target: input }))).toBe(false)
const dialog = document.createElement('div')
dialog.setAttribute('role', 'dialog')
const button = document.createElement('button')
dialog.appendChild(button)
document.body.appendChild(dialog)
expect(shouldHandleAutomationRunHistoryKey(makeEvent({ target: button }))).toBe(false)
dialog.remove()
})
it('leaves Enter to a focused button or link but still handles arrows there', () => {
const button = document.createElement('button')
const link = document.createElement('a')
link.setAttribute('href', '#')
expect(shouldHandleAutomationRunHistoryKey(makeEvent({ key: 'Enter', target: button }))).toBe(
false
)
expect(shouldHandleAutomationRunHistoryKey(makeEvent({ key: 'Enter', target: link }))).toBe(
false
)
const tabTrigger = document.createElement('div')
tabTrigger.setAttribute('role', 'tab')
expect(
shouldHandleAutomationRunHistoryKey(makeEvent({ key: 'Enter', target: tabTrigger }))
).toBe(false)
expect(
shouldHandleAutomationRunHistoryKey(makeEvent({ key: 'ArrowDown', target: button }))
).toBe(true)
})
})
describe('getAutomationRunHistoryArrowTarget', () => {
const run1 = makeRun({ id: 'run-1' })
const run2 = makeRun({ id: 'run-2' })
const run3 = makeRun({ id: 'run-3' })
const runs = [run1, run2, run3]
it('returns null for empty runs list', () => {
expect(
getAutomationRunHistoryArrowTarget({
runs: [],
selectedRunId: null,
key: 'ArrowDown'
})
).toBeNull()
})
it('moves selection down from first item to second item', () => {
expect(
getAutomationRunHistoryArrowTarget({
runs,
selectedRunId: 'run-1',
key: 'ArrowDown'
})
).toBe(run2)
})
it('moves selection up from second item to first item', () => {
expect(
getAutomationRunHistoryArrowTarget({
runs,
selectedRunId: 'run-2',
key: 'ArrowUp'
})
).toBe(run1)
})
it('clamps at the bottom of the runs list', () => {
expect(
getAutomationRunHistoryArrowTarget({
runs,
selectedRunId: 'run-3',
key: 'ArrowDown'
})
).toBe(run3)
})
it('clamps at the top of the runs list', () => {
expect(
getAutomationRunHistoryArrowTarget({
runs,
selectedRunId: 'run-1',
key: 'ArrowUp'
})
).toBe(run1)
})
it('defaults to index 0 on ArrowDown when nothing was selected', () => {
expect(
getAutomationRunHistoryArrowTarget({
runs,
selectedRunId: null,
key: 'ArrowDown'
})
).toBe(run2)
})
})
@@ -0,0 +1,74 @@
import type { AutomationRun } from '../../../../shared/automations-types'
export type AutomationRunHistoryArrowKey = 'ArrowUp' | 'ArrowDown'
export function isAutomationRunHistoryArrowKey(key: string): key is AutomationRunHistoryArrowKey {
return key === 'ArrowUp' || key === 'ArrowDown'
}
export function shouldHandleAutomationRunHistoryKey(event: {
key: string
altKey: boolean
ctrlKey: boolean
metaKey: boolean
shiftKey: boolean
nativeEvent?: { isComposing?: boolean }
target?: EventTarget | null
}): boolean {
if (
(!isAutomationRunHistoryArrowKey(event.key) && event.key !== 'Enter') ||
Boolean(event.nativeEvent?.isComposing) ||
event.altKey ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey
) {
return false
}
const target = event.target
if (target instanceof HTMLElement) {
if (
target.isContentEditable ||
target.matches(
'input, textarea, select, [contenteditable="true"], [contenteditable=""], [role="textbox"]'
)
) {
return false
}
if (target.closest('[role="dialog"], [role="menu"], [role="listbox"]')) {
return false
}
// Enter belongs to the focused control; a focused run row is a button that opens itself on click.
if (
event.key === 'Enter' &&
target.closest(
'button, a[href], summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="checkbox"], [role="switch"]'
)
) {
return false
}
}
return true
}
export function getAutomationRunHistoryArrowTarget(args: {
runs: readonly AutomationRun[]
selectedRunId: string | null
key: AutomationRunHistoryArrowKey
}): AutomationRun | null {
const { runs, selectedRunId, key } = args
if (runs.length === 0) {
return null
}
const currentIndex = selectedRunId ? runs.findIndex((run) => run.id === selectedRunId) : 0
if (currentIndex < 0) {
return runs[key === 'ArrowDown' ? 0 : runs.length - 1] ?? null
}
const nextIndex = key === 'ArrowDown' ? currentIndex + 1 : currentIndex - 1
if (nextIndex < 0 || nextIndex >= runs.length) {
return runs[currentIndex] ?? null
}
return runs[nextIndex] ?? null
}