mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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
|
||||
}
|
||||
+92
-1
@@ -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)
|
||||
}
|
||||
|
||||
+169
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user