feat: focus & keyboard navigation improvements [INS-2552] (#10049)

* feat: focus and keyboard navigation improvements [INS-2552]

Make common create/edit flows land the cursor where you'd start typing,
and let the navigation sidebar expand/collapse folders with the arrow keys.

- New request focuses the URL bar
- Adding a query param / header focuses the new row's Name cell
- Opening the KV environment editor focuses the trailing blank row's Name
- Create/rename/settings dialogs focus the Name field
- Sidebar Left/Right arrows collapse/expand the focused folder
- Cmd/Ctrl-N creates the request inside the selected folder

OneLineEditor gains autoFocus/onAutoFocus. The editor focus is deferred a
frame so it wins against React Aria ListBox focus restoration, and the
"new request" signal is module-level (read in render, cleared on focus) to
stay correct under React StrictMode double-mounting.

Adds focus-and-keyboard smoke tests to guard against regressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: make autofocus survive React Aria ListBox focus restoration [INS-2552]

The param/header/environment grids wrap their inputs in a React Aria
ListBox, which restores DOM focus to the row right after the editor focuses
itself. A single deferred focus won this race locally but lost on slower
headless CI, so the grid/env focus smoke tests flaked.

Re-assert focus across a short bounded window (rAF, up to 500ms), re-grabbing
only when focus was bounced to a non-editable element (the row) and never when
the user moved to another field, until the editor holds focus or the window
elapses.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: key grid autofocus off the new pair id, not a shared flag [INS-2552]

The previous module-level boolean was shared across every KeyValueEditor
instance, so a concurrently mounting/remounting grid could consume it and
focus an unrelated row. Store the specific new pair's id instead (it survives
the async save and the remount) so only the newly added row can autofocus,
and clear it once that exact row focuses.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Fix test

* Fix test

* Fix test

* Fix tests

* Typo

* Fix tests

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Pavlos Koutoglou
2026-07-01 02:15:33 +02:00
committed by GitHub
parent 256a6f9b0e
commit 99d9a3d79b
14 changed files with 410 additions and 25 deletions

View File

@@ -0,0 +1,188 @@
import { expect } from '@playwright/test';
import { loadFixture } from '../../playwright/paths';
import { test } from '../../playwright/test';
// Regression coverage for the focus / keyboard-navigation rules:
// - creating a request focuses the URL bar
// - adding a param/header focuses the new row's Name cell
// - opening the KV environment editor focuses the trailing blank row's Name
// - create/rename/settings dialogs focus the Name field
// - the navigation sidebar expands/collapses folders with Left/Right arrows
// - Cmd/Ctrl-N adds the new request inside the selected folder
//
// CodeMirror-backed editors (OneLineEditor) expose focus via `data-focused="on"` on their
// `.editor__container`, which is what these tests assert against.
const focusedEditorWithChild = (childIdPrefix: string) =>
`.editor__container:has([id^="${childIdPrefix}"]) .CodeMirror-focused`;
test.describe('Focus and keyboard navigation', () => {
test.slow(process.platform === 'darwin' || process.platform === 'win32', 'Slow app start on these platforms');
test('creating a request focuses the URL bar', async ({ page, insomnia }) => {
await page.getByRole('button', { name: 'Create request collection', exact: true }).click();
// Create a fresh HTTP request (this goes through the create -> redirect path that focuses the URL).
await insomnia.navigationSidebar.openWorkspaceActionsDropdown('My first collection');
await page.getByRole('menuitemradio', { name: 'Http Request' }).click();
// The URL bar should be focused so the user can start typing immediately.
await expect.soft(page.locator(focusedEditorWithChild('request-url-bar'))).toHaveCount(1);
});
test('adding a query parameter focuses the Name cell', async ({ page }) => {
await page.getByRole('button', { name: 'Create request collection', exact: true }).click();
await page.getByRole('tab', { name: 'Params' }).click();
await page.getByTestId('request-pane').getByRole('button', { name: 'Add', exact: true }).click();
await expect.soft(page.locator(focusedEditorWithChild('key-value-editor__name'))).toHaveCount(1);
});
test('adding a header focuses the Name cell', async ({ page }) => {
await page.getByRole('button', { name: 'Create request collection', exact: true }).click();
await page.getByRole('tab', { name: 'Headers' }).click();
await page.getByTestId('request-pane').getByRole('button', { name: 'Add', exact: true }).click();
await expect.soft(page.locator(focusedEditorWithChild('key-value-editor__name'))).toHaveCount(1);
});
test('the KV environment editor focuses the blank row Name', async ({ page }) => {
await page.getByRole('button', { name: 'Create request collection', exact: true }).click();
await page.getByRole('button', { name: 'Manage Environments' }).click();
await page.getByRole('button', { name: 'Manage collection environments' }).click();
await page.getByTestId('CreateEnvironmentDropdown').click();
await page.getByRole('menuitemradio', { name: 'Shared Environment' }).press('Enter');
// New environments default to KV mode, so selecting the new (empty) environment renders the KV
// editor whose trailing blank row's Name should be focused.
const newEnvironmentRow = page.getByRole('row', { name: 'New Environment' });
await newEnvironmentRow.waitFor({ state: 'visible' });
// Click the painted name cell rather than the row's center: the row's flexible middle leaves a
// gap that the modal's pane container reports as intercepting the click, and the blank-row
// autofocus churns focus/scroll right after the editor mounts.
await newEnvironmentRow.locator('[data-editable=true]').click();
await expect.soft(page.locator(focusedEditorWithChild('environment-kv-editor-name'))).toHaveCount(1);
});
test('workspace settings dialog focuses the Name field', async ({ page, insomnia }) => {
await page.getByRole('button', { name: 'Create request collection', exact: true }).click();
await insomnia.navigationSidebar.openWorkspaceActionsDropdown('My first collection');
await page.getByRole('menuitemradio', { name: 'Settings' }).click();
await expect.soft(page.getByRole('dialog').getByRole('textbox', { name: 'Name' })).toBeFocused();
});
test('tabbing onto the params grid focuses the Name cell', async ({ page }) => {
await page.getByRole('button', { name: 'Create request collection', exact: true }).click();
await page.getByRole('tab', { name: 'Params' }).click();
// Move focus onto the params grid the way Tab would: React Aria lands focus on a row, which the
// editor forwards into the trailing blank row's Name editor so the user can start typing a new pair.
await page.getByRole('listbox', { name: 'Key-value pairs' }).getByRole('option').first().focus();
await expect.soft(page.locator(focusedEditorWithChild('key-value-editor__name'))).toHaveCount(1);
});
test('Tab from the URL bar moves focus to Send', async ({ page, insomnia }) => {
await page.getByRole('button', { name: 'Create request collection', exact: true }).click();
await insomnia.navigationSidebar.openWorkspaceActionsDropdown('My first collection');
await page.getByRole('menuitemradio', { name: 'Http Request' }).click();
// Fresh request focuses the URL bar; Tab should advance to the Send button. (The URL autofocus
// re-grab loop must not yank focus back when Tab lands on the Send button.)
await expect.soft(page.locator(focusedEditorWithChild('request-url-bar'))).toHaveCount(1);
await page.keyboard.press('Tab');
await expect.soft(page.getByRole('button', { name: 'Send', exact: true })).toBeFocused();
});
test('Tab reaches the request tabs and they show a keyboard focus ring', async ({ page, insomnia }) => {
await page.getByRole('button', { name: 'Create request collection', exact: true }).click();
await insomnia.navigationSidebar.openWorkspaceActionsDropdown('My first collection');
await page.getByRole('menuitemradio', { name: 'Http Request' }).click();
await expect.soft(page.locator(focusedEditorWithChild('request-url-bar'))).toHaveCount(1);
// Tab order out of the URL bar: Send -> send dropdown -> request tablist (Params).
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
const paramsTab = page.getByRole('tab', { name: 'Params' });
await expect.soft(paramsTab).toBeFocused();
// React Aria marks keyboard focus with data-focus-visible, which drives the visible focus ring.
await expect.soft(paramsTab).toHaveAttribute('data-focus-visible', 'true');
// Arrow keys move between the request tabs.
await page.keyboard.press('ArrowRight');
await expect.soft(page.getByRole('tab', { name: 'Body' })).toBeFocused();
});
test.describe('with an imported collection', () => {
test.beforeEach(async ({ app, page }) => {
const text = await loadFixture('simple.yaml');
await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), text);
await page.getByLabel('Import').click();
await page.locator('[data-test-id="import-from-clipboard"]').click();
await page.getByRole('button', { name: 'Scan' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Import' }).click();
await page.getByRole('dialog').waitFor({ state: 'hidden' });
// Import lands in the imported collection's debug view; wait for its folder to appear in the sidebar.
await page.getByTestId('request-node-test folder').waitFor({ state: 'visible' });
});
test('Left/Right arrows collapse and expand a folder', async ({ page }) => {
const folderRow = page.locator('[role="row"]:has([data-testid="request-node-test folder"])');
// ArrowRight always expands (no-op if already expanded), ArrowLeft always collapses.
await folderRow.press('ArrowRight');
await expect.soft(page.getByLabel('Collapse test folder')).toBeVisible();
await folderRow.press('ArrowLeft');
await expect.soft(page.getByLabel('Expand test folder')).toBeVisible();
await folderRow.press('ArrowRight');
await expect.soft(page.getByLabel('Collapse test folder')).toBeVisible();
});
test('Cmd/Ctrl-N adds the request inside the selected folder', async ({ page }) => {
const folderRow = page.locator('[role="row"]:has([data-testid="request-node-test folder"])');
// Collapse the folder (ArrowLeft is a no-op if already collapsed) so its later auto-expansion
// is a clean signal that the new request was nested inside it.
await folderRow.press('ArrowLeft');
await expect.soft(page.getByLabel('Expand test folder')).toBeVisible();
// Select the folder (no request active), then create a request via the keyboard shortcut.
await page.getByTestId('request-node-test folder').click();
await page.locator('.app').press('ControlOrMeta+n');
// Navigating to the new request auto-expands its ancestor folder, proving it was created inside.
await expect.soft(page.getByLabel('Collapse test folder')).toBeVisible();
});
test('request settings dialog focuses the Name field', async ({ page, insomnia }) => {
await insomnia.navigationSidebar.selectRequestDropdownOption({
actionName: 'Settings',
requestName: 'example http',
});
await expect.soft(page.getByRole('dialog').getByRole('textbox', { name: 'Name' })).toBeFocused();
});
test('folder settings dialog focuses the Name field', async ({ page, insomnia }) => {
await insomnia.navigationSidebar.selectRequestGroupDropdownOption({
actionName: 'Settings',
requestGroupName: 'test folder',
});
await expect.soft(page.getByRole('dialog').getByRole('textbox', { name: 'Name' })).toBeFocused();
});
});
});

View File

@@ -105,33 +105,42 @@ test.describe('Check vault used in environment', () => {
// add first secret environment
const firstRow = kvTable.getByRole('option').first();
await firstRow.getByTestId('OneLineEditor').first().click();
const firstKey = firstRow.getByTestId('OneLineEditor').first();
await firstKey.click();
await page.keyboard.type('foo');
await firstRow.getByTestId('OneLineEditor').nth(1).click({ delay: 200 });
await page.keyboard.type('bar');
// Delay the click to let debounce finish
await expect.soft(firstKey).toContainText('foo');
// Convert the row to Secret *before* entering the value, then type the secret directly into the
// revealed editor. Converting a just-typed string to Secret races the async persistence
// round-trip (the editor's change goes through a fetcher submit + revalidation): the typed value
// may not be committed yet when the conversion reads it, so an empty string gets encrypted and the
// revealed secret comes back blank. Typing into the already-Secret field encrypts each keystroke,
// so there is no stale value to read.
await firstRow.getByRole('button', { name: 'Type Selection' }).click({ delay: 200 });
await page.getByRole('menuitemradio', { name: 'Secret' }).click();
await expect.soft(firstRow.locator('.fa-eye-slash')).toBeVisible();
// reveal the secret editor and type the value into it
await firstRow.locator('.fa-eye-slash').click();
// test decrypt secret in UI
await expect.soft(firstRow.getByTestId('OneLineEditor').nth(1)).toContainText('bar');
const firstValue = firstRow.getByTestId('OneLineEditor').nth(1);
await firstValue.click({ delay: 200 });
await page.keyboard.type('bar');
// test the secret value is shown decrypted in the UI
await expect.soft(firstValue).toContainText('bar');
// add second secret environment
// add second secret environment (same order as above: convert to Secret first, then type the value)
await page.getByRole('button', { name: 'Add Row' }).click();
const secondRow = kvTable.getByRole('option').nth(1);
await secondRow.getByTestId('OneLineEditor').first().click();
const secondKey = secondRow.getByTestId('OneLineEditor').first();
await secondKey.click();
await page.keyboard.type('hello');
await secondRow.getByTestId('OneLineEditor').nth(1).click({ delay: 200 });
await page.keyboard.type('world');
// Delay the click to let debounce finish
await expect.soft(secondKey).toContainText('hello');
await secondRow.getByRole('button', { name: 'Type Selection' }).click({ delay: 200 });
await page.getByRole('menuitemradio', { name: 'Secret' }).click();
// ensure the secret value has been persisted before navigating away, otherwise
// the request below pops a "1 environment variable is missing" modal for vault.hello
await expect.soft(secondRow.locator('.fa-eye-slash')).toBeVisible();
await secondRow.locator('.fa-eye-slash').click();
await expect.soft(secondRow.getByTestId('OneLineEditor').nth(1)).toContainText('world');
const secondValue = secondRow.getByTestId('OneLineEditor').nth(1);
await secondValue.click({ delay: 200 });
await page.keyboard.type('world');
await expect.soft(secondValue).toContainText('world');
// go back
await page.getByTestId('workspace-breadcrumb-level-0').click();

View File

@@ -12,10 +12,14 @@ import {
import { invariant } from '~/common/utils/invariant';
import type { RequestCreatedMetricsProperties } from '~/ui/analytics';
import { AnalyticsEvent } from '~/ui/analytics';
import { focusUrlBarOnNextRequest } from '~/ui/components/request-url-bar-focus';
import { trackCioEvent } from '~/ui/hooks/use-cio';
import type { CreateRequestType } from '~/ui/hooks/use-request';
import { createFetcherSubmitHook } from '~/ui/utils/router';
// Request types that are edited in the RequestPane / RequestUrlBar and should focus the URL on create.
const URL_BAR_REQUEST_TYPES: CreateRequestType[] = ['HTTP', 'GraphQL', 'Event Stream', 'From Curl'];
import type { Route } from './+types/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.new';
export async function clientAction({ params, request }: Route.ClientActionArgs) {
@@ -190,6 +194,11 @@ export const useRequestNewActionFetcher = createFetcherSubmitHook(
workspaceId,
});
// Focus the URL bar once the newly created request opens so the user can start typing.
if (URL_BAR_REQUEST_TYPES.includes(requestType)) {
focusUrlBarOnNextRequest();
}
return submit(JSON.stringify({ requestType, parentId, req, metrics }), {
action: url,
method: 'POST',

View File

@@ -414,7 +414,13 @@ const Debug = () => {
}
},
request_createHTTP: async () => {
const parentId = activeRequest ? activeRequest.parentId : activeWorkspace._id;
// When a request is active, create a sibling; when a folder is selected (and no request is
// active), create inside that folder; otherwise create at the workspace root.
const parentId = activeRequest
? activeRequest.parentId
: requestGroupId && isRequestGroupId(requestGroupId)
? requestGroupId
: activeWorkspace._id;
createRequestFetcher.submit({
organizationId,
projectId,

View File

@@ -62,6 +62,9 @@ export interface OneLineEditorProps {
eventListeners?: EditorEventListener<keyof EditorEventMap>[];
// NOTE: stable key for caching/restoring undo history across remounts
uniquenessKey?: string;
autoFocus?: boolean;
// Called once when the editor focuses itself due to `autoFocus`. Lets callers clear a one-shot flag.
onAutoFocus?: () => void;
}
export interface EditorEventListener<T extends keyof EditorEventMap> {
@@ -88,6 +91,8 @@ export const OneLineEditor = forwardRef<OneLineEditorHandle, OneLineEditorProps>
onBlur,
eventListeners,
uniquenessKey,
autoFocus,
onAutoFocus,
},
ref,
) => {
@@ -311,6 +316,56 @@ export const OneLineEditor = forwardRef<OneLineEditorHandle, OneLineEditorProps>
}
});
reactUse.useMount(() => {
initEditor();
if (autoFocus && !readOnly) {
onAutoFocus?.();
// An enclosing React Aria ListBox (params/headers/environment grids) restores DOM focus to
// the row right after we focus the editor, and a single deferred focus loses that race on
// slower/headless machines. So we re-assert focus across a short window, re-grabbing only when
// focus was bounced to a non-interactive element (the row) — never when the user deliberately
// moved to another control (e.g. Tab from the URL bar to Send) — until the editor holds focus
// or the window elapses.
const deadline = Date.now() + 500;
const ensureFocus = () => {
const cm = codeMirror.current;
if (!cm) {
return;
}
if (!cm.hasFocus()) {
const active = document.activeElement as HTMLElement | null;
// The row React Aria bounces focus to is a non-interactive container (role="row"/"option");
// anything genuinely interactive (a field, button, link, menu item, etc.) means the user
// moved on purpose, so we must not steal focus back.
const role = active?.getAttribute('role');
const userMovedToAnotherControl =
!!active &&
(active.tagName === 'INPUT' ||
active.tagName === 'TEXTAREA' ||
active.tagName === 'SELECT' ||
active.tagName === 'BUTTON' ||
active.tagName === 'A' ||
active.isContentEditable ||
role === 'button' ||
role === 'link' ||
role === 'menuitem' ||
role === 'menuitemradio' ||
role === 'checkbox' ||
role === 'tab');
if (userMovedToAnotherControl) {
return;
}
cm.focus();
cm.getDoc().setCursor(cm.getDoc().lineCount(), 0);
}
if (Date.now() < deadline) {
requestAnimationFrame(ensureFocus);
}
};
requestAnimationFrame(ensureFocus);
}
});
reactUse.useUnmount(() => {
persistState();
cleanUpEditor();

View File

@@ -76,7 +76,7 @@ export const EnvironmentKVEditor = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
[JSON.stringify(data)],
);
const blankNameEditorRef = useRef<OneLineEditorHandle>(null);
const blankNameEditorRef = useRef<OneLineEditorHandle | null>(null);
// The id for the trailing blank row is derived from the persisted pairs (rather than
// held in state) so it only changes when the data actually changes. This keeps it in
// sync with the async data updates - if it flipped eagerly the row the user just typed
@@ -96,6 +96,11 @@ export const EnvironmentKVEditor = ({
// diffs) until the user starts typing in it.
const kvPairs: EnvironmentKvPairData[] = useMemo(() => [...persistedPairs, blankPair], [persistedPairs, blankPair]);
const codeModalRef = useRef<CodePromptModalHandle>(null);
// Refs to each row's Name editor, keyed by pair id. React Aria's ListBox (with drag-and-drop) owns
// roving focus and lands it on the row element; we hand that focus into the row's CodeMirror editor
// rather than fighting React Aria with an imperative focus loop (which corrupts the modal's
// ariaHideOutside/inert management on the sibling environments list).
const nameEditorRefs = useRef<Map<string, OneLineEditorHandle>>(new Map());
const [kvPairError, setKvPairError] = useState<{ id: string; error: string }[]>([]);
const [decryptedValues, setDecryptedValues] = useState<Record<string, string>>({});
const symmetricKey = useMemo(() => (vaultKey === '' ? {} : base64decode(vaultKey, true)), [vaultKey]);
@@ -305,7 +310,16 @@ export const EnvironmentKVEditor = ({
)}
<div className={`${cellCommonStyle} relative flex h-full w-[30%] grow pl-1`}>
<OneLineEditor
ref={isBlank ? blankNameEditorRef : undefined}
ref={el => {
if (el) {
nameEditorRefs.current.set(id, el);
} else {
nameEditorRefs.current.delete(id);
}
if (isBlank) {
blankNameEditorRef.current = el;
}
}}
id={`environment-kv-editor-name-${id}`}
placeholder={'Input Name'}
defaultValue={name}
@@ -515,9 +529,13 @@ export const EnvironmentKVEditor = ({
dependencies={[kvPairError, data, symmetricKey, blankId]}
className="h-full w-full overflow-y-auto p-(--padding-sm)"
items={kvPairs}
// Let React Aria place focus on the trailing blank row so the editor is ready to type into on
// open/add — then onFocus below hands that focus into the row's Name editor.
autoFocus={!disabled && kvPairs.length > 0 && kvPairs[kvPairs.length - 1].name === '' ? 'last' : undefined}
>
{kvPair => {
const { id, name, enabled } = kvPair;
const isTrailingBlankRow = name === '' && kvPair === kvPairs[kvPairs.length - 1];
return (
<ListBoxItem
key={id}
@@ -525,6 +543,13 @@ export const EnvironmentKVEditor = ({
textValue={`environment-item-${name || id}`}
style={{ opacity: enabled ? '1' : '0.4' }}
className={'flex h-(--line-height-sm) w-full focus:outline-hidden'}
onFocus={e => {
// Forward focus from the row element into its Name editor, but only for the trailing
// blank row and only when the row itself (not an inner field) received focus.
if (!disabled && isTrailingBlankRow && e.target === e.currentTarget) {
nameEditorRefs.current.get(id)?.focusEnd();
}
}}
>
{renderPairItem(kvPair)}
</ListBoxItem>

View File

@@ -35,6 +35,13 @@ interface Pair {
canDisable?: boolean;
}
// Id of the row whose Name cell should grab focus after an "Add". Module-level so it survives the
// KeyValueEditor remount that the async pair save triggers, and so it isn't prematurely consumed under
// React StrictMode. Keying off the specific id (rather than a shared boolean) means only the newly
// added row can autofocus — never an unrelated row in another mounted KeyValueEditor. Set on "Add",
// read during render, and cleared once that exact row's Name editor focuses (via onAutoFocus).
let pendingFocusLastRowId: string | null = null;
function createEmptyPair() {
return {
id: generateId('pair'),
@@ -275,6 +282,7 @@ export const KeyValueEditor: FC<Props> = ({
className="flex h-full items-center justify-center gap-2 px-4 py-1 text-xs text-(--color-font) ring-1 ring-transparent transition-all hover:bg-(--hl-xs) focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm)"
onPress={() => {
const id = generateId('pair');
pendingFocusLastRowId = id;
upsertPair({ id, name: '', value: '', description: '', disabled: false });
}}
>
@@ -505,6 +513,23 @@ export const KeyValueEditor: FC<Props> = ({
textValue={pair.name + '-' + pair.value}
style={{ opacity: pair.disabled ? '0.4' : '1' }}
className={`relative grid h-(--line-height-sm) shrink-0 gap-2 bg-(--color-bg) px-2 outline-hidden ${showDescription ? 'grid-cols-[max-content_1fr_1fr_1fr_max-content]' : 'grid-cols-[max-content_1fr_1fr_max-content]'}`}
onFocus={event => {
if (isDisabled) {
return;
}
// Only react when the row element itself takes focus, never an inner editor/control.
if (event.target !== event.currentTarget) {
return;
}
const listbox = event.currentTarget.closest('[role="listbox"]');
const enteredFromOutside = !listbox?.contains(event.relatedTarget as Node | null);
// Tabbing onto the grid (focus entering from outside) or landing on the trailing blank
// row drops the cursor into the blank row's Name editor so the user can start typing a
// new pair immediately. Arrow-key navigation between existing rows is left untouched.
if (enteredFromOutside || isBlank) {
blankNameEditorRef.current?.focusEnd();
}
}}
>
<div
slot="drag"
@@ -520,6 +545,12 @@ export const KeyValueEditor: FC<Props> = ({
placeholder={namePlaceholder || 'Name'}
defaultValue={pair.name}
readOnly={pair.disabled || isDisabled}
autoFocus={pair.id === pendingFocusLastRowId}
onAutoFocus={() => {
if (pendingFocusLastRowId === pair.id) {
pendingFocusLastRowId = null;
}
}}
getAutocompleteConstants={() => handleGetAutocompleteNameConstants?.(pair) || []}
onChange={name => {
upsertPair({ ...pair, name });

View File

@@ -94,6 +94,7 @@ export const RequestGroupSettingsModal = ({
<label>
Name
<input
autoFocus
type="text"
placeholder={requestGroup?.name || 'My Folder'}
defaultValue={requestGroup?.name}

View File

@@ -126,6 +126,7 @@ export const RequestSettingsModal = ({ request, onHide }: ModalProps & RequestSe
<label>
Name <span className="txt-sm faint italic">(also rename by double-clicking in sidebar)</span>
<input
autoFocus
type="text"
placeholder={request?.url || 'My Request'}
defaultValue={request?.name}

View File

@@ -170,6 +170,7 @@ export const WorkspaceSettingsModal = ({ workspace, gitFilePath, project, mockSe
>
<Label className="text-sm text-(--hl)">Name</Label>
<Input
autoFocus={!isScratchpadWorkspace}
placeholder="Awesome API"
className="w-full rounded-xs border border-solid border-(--hl-sm) bg-(--color-bg) p-2 text-(--color-font) transition-colors focus:ring-1 focus:ring-(--hl-md) focus:outline-hidden"
/>

View File

@@ -121,7 +121,7 @@ export const RequestPane: FC<Props> = ({ environmentId, settings, onPaste }) =>
aria-label="Request pane tabs"
>
<Tab
className="flex h-full shrink-0 cursor-pointer items-center justify-between gap-2 px-3 py-1 text-(--hl) outline-hidden transition-colors duration-300 select-none hover:bg-(--hl-sm) hover:text-(--color-font) focus:bg-(--hl-sm) aria-selected:bg-(--hl-xs) aria-selected:text-(--color-font) aria-selected:hover:bg-(--hl-sm) aria-selected:focus:bg-(--hl-sm)"
className="flex h-full shrink-0 cursor-pointer items-center justify-between gap-2 px-3 py-1 text-(--hl) outline-hidden transition-colors duration-300 select-none hover:bg-(--hl-sm) hover:text-(--color-font) focus:bg-(--hl-sm) aria-selected:bg-(--hl-xs) aria-selected:text-(--color-font) aria-selected:hover:bg-(--hl-sm) aria-selected:focus:bg-(--hl-sm) data-[focus-visible=true]:ring-2 data-[focus-visible=true]:ring-inset data-[focus-visible=true]:ring-(--hl-md)"
id="params"
>
<span>Params</span>
@@ -132,7 +132,7 @@ export const RequestPane: FC<Props> = ({ environmentId, settings, onPaste }) =>
)}
</Tab>
<Tab
className="flex h-full shrink-0 cursor-pointer items-center justify-between gap-2 px-3 py-1 text-(--hl) outline-hidden transition-colors duration-300 select-none hover:bg-(--hl-sm) hover:text-(--color-font) focus:bg-(--hl-sm) aria-selected:bg-(--hl-xs) aria-selected:text-(--color-font) aria-selected:hover:bg-(--hl-sm) aria-selected:focus:bg-(--hl-sm)"
className="flex h-full shrink-0 cursor-pointer items-center justify-between gap-2 px-3 py-1 text-(--hl) outline-hidden transition-colors duration-300 select-none hover:bg-(--hl-sm) hover:text-(--color-font) focus:bg-(--hl-sm) aria-selected:bg-(--hl-xs) aria-selected:text-(--color-font) aria-selected:hover:bg-(--hl-sm) aria-selected:focus:bg-(--hl-sm) data-[focus-visible=true]:ring-2 data-[focus-visible=true]:ring-inset data-[focus-visible=true]:ring-(--hl-md)"
id="content-type"
>
<span>Body</span>
@@ -143,7 +143,7 @@ export const RequestPane: FC<Props> = ({ environmentId, settings, onPaste }) =>
)}
</Tab>
<Tab
className="flex h-full shrink-0 cursor-pointer items-center justify-between gap-2 px-3 py-1 text-(--hl) outline-hidden transition-colors duration-300 select-none hover:bg-(--hl-sm) hover:text-(--color-font) focus:bg-(--hl-sm) aria-selected:bg-(--hl-xs) aria-selected:text-(--color-font) aria-selected:hover:bg-(--hl-sm) aria-selected:focus:bg-(--hl-sm)"
className="flex h-full shrink-0 cursor-pointer items-center justify-between gap-2 px-3 py-1 text-(--hl) outline-hidden transition-colors duration-300 select-none hover:bg-(--hl-sm) hover:text-(--color-font) focus:bg-(--hl-sm) aria-selected:bg-(--hl-xs) aria-selected:text-(--color-font) aria-selected:hover:bg-(--hl-sm) aria-selected:focus:bg-(--hl-sm) data-[focus-visible=true]:ring-2 data-[focus-visible=true]:ring-inset data-[focus-visible=true]:ring-(--hl-md)"
id="auth"
>
<span>Auth</span>
@@ -155,7 +155,7 @@ export const RequestPane: FC<Props> = ({ environmentId, settings, onPaste }) =>
)}
</Tab>
<Tab
className="flex h-full shrink-0 cursor-pointer items-center justify-between gap-2 px-3 py-1 text-(--hl) outline-hidden transition-colors duration-300 select-none hover:bg-(--hl-sm) hover:text-(--color-font) focus:bg-(--hl-sm) aria-selected:bg-(--hl-xs) aria-selected:text-(--color-font) aria-selected:hover:bg-(--hl-sm) aria-selected:focus:bg-(--hl-sm)"
className="flex h-full shrink-0 cursor-pointer items-center justify-between gap-2 px-3 py-1 text-(--hl) outline-hidden transition-colors duration-300 select-none hover:bg-(--hl-sm) hover:text-(--color-font) focus:bg-(--hl-sm) aria-selected:bg-(--hl-xs) aria-selected:text-(--color-font) aria-selected:hover:bg-(--hl-sm) aria-selected:focus:bg-(--hl-sm) data-[focus-visible=true]:ring-2 data-[focus-visible=true]:ring-inset data-[focus-visible=true]:ring-(--hl-md)"
id="headers"
>
<span>Headers</span>
@@ -166,7 +166,7 @@ export const RequestPane: FC<Props> = ({ environmentId, settings, onPaste }) =>
)}
</Tab>
<Tab
className="flex h-full shrink-0 cursor-pointer items-center justify-between gap-2 px-3 py-1 text-(--hl) outline-hidden transition-colors duration-300 select-none hover:bg-(--hl-sm) hover:text-(--color-font) focus:bg-(--hl-sm) aria-selected:bg-(--hl-xs) aria-selected:text-(--color-font) aria-selected:hover:bg-(--hl-sm) aria-selected:focus:bg-(--hl-sm)"
className="flex h-full shrink-0 cursor-pointer items-center justify-between gap-2 px-3 py-1 text-(--hl) outline-hidden transition-colors duration-300 select-none hover:bg-(--hl-sm) hover:text-(--color-font) focus:bg-(--hl-sm) aria-selected:bg-(--hl-xs) aria-selected:text-(--color-font) aria-selected:hover:bg-(--hl-sm) aria-selected:focus:bg-(--hl-sm) data-[focus-visible=true]:ring-2 data-[focus-visible=true]:ring-inset data-[focus-visible=true]:ring-(--hl-md)"
id="scripts"
>
<span>Scripts</span>
@@ -177,7 +177,7 @@ export const RequestPane: FC<Props> = ({ environmentId, settings, onPaste }) =>
)}
</Tab>
<Tab
className="flex h-full shrink-0 cursor-pointer items-center justify-between gap-2 px-3 py-1 text-(--hl) outline-hidden transition-colors duration-300 select-none hover:bg-(--hl-sm) hover:text-(--color-font) focus:bg-(--hl-sm) aria-selected:bg-(--hl-xs) aria-selected:text-(--color-font) aria-selected:hover:bg-(--hl-sm) aria-selected:focus:bg-(--hl-sm)"
className="flex h-full shrink-0 cursor-pointer items-center justify-between gap-2 px-3 py-1 text-(--hl) outline-hidden transition-colors duration-300 select-none hover:bg-(--hl-sm) hover:text-(--color-font) focus:bg-(--hl-sm) aria-selected:bg-(--hl-xs) aria-selected:text-(--color-font) aria-selected:hover:bg-(--hl-sm) aria-selected:focus:bg-(--hl-sm) data-[focus-visible=true]:ring-2 data-[focus-visible=true]:ring-inset data-[focus-visible=true]:ring-(--hl-md)"
id="docs"
>
<span>Docs</span>
@@ -336,7 +336,7 @@ export const RequestPane: FC<Props> = ({ environmentId, settings, onPaste }) =>
aria-label="Request scripts tabs"
>
<Tab
className="flex h-(--line-height-xxs) w-42 shrink-0 cursor-pointer items-center justify-between rounded-md px-2 py-1 text-sm text-(--hl) outline-hidden transition-colors duration-300 select-none hover:bg-[rgba(var(--color-surprise-rgb),50%)] hover:text-(--color-font-surprise) aria-selected:bg-[rgba(var(--color-surprise-rgb),40%)] aria-selected:text-(--color-font-surprise)"
className="flex h-(--line-height-xxs) w-42 shrink-0 cursor-pointer items-center justify-between rounded-md px-2 py-1 text-sm text-(--hl) outline-hidden transition-colors duration-300 select-none hover:bg-[rgba(var(--color-surprise-rgb),50%)] hover:text-(--color-font-surprise) aria-selected:bg-[rgba(var(--color-surprise-rgb),40%)] aria-selected:text-(--color-font-surprise) data-[focus-visible=true]:ring-2 data-[focus-visible=true]:ring-inset data-[focus-visible=true]:ring-(--hl-md)"
id="pre-request"
>
<div className="flex flex-1 items-center gap-2">
@@ -350,7 +350,7 @@ export const RequestPane: FC<Props> = ({ environmentId, settings, onPaste }) =>
)}
</Tab>
<Tab
className="flex h-(--line-height-xxs) w-42 shrink-0 cursor-pointer items-center justify-between rounded-md px-2 py-1 text-sm text-(--hl) outline-hidden transition-colors duration-300 select-none hover:bg-[rgba(var(--color-surprise-rgb),50%)] hover:text-(--color-font-surprise) aria-selected:bg-[rgba(var(--color-surprise-rgb),40%)] aria-selected:text-(--color-font-surprise)"
className="flex h-(--line-height-xxs) w-42 shrink-0 cursor-pointer items-center justify-between rounded-md px-2 py-1 text-sm text-(--hl) outline-hidden transition-colors duration-300 select-none hover:bg-[rgba(var(--color-surprise-rgb),50%)] hover:text-(--color-font-surprise) aria-selected:bg-[rgba(var(--color-surprise-rgb),40%)] aria-selected:text-(--color-font-surprise) data-[focus-visible=true]:ring-2 data-[focus-visible=true]:ring-inset data-[focus-visible=true]:ring-(--hl-md)"
id="after-response"
>
<div className="flex flex-1 items-center gap-2">

View File

@@ -0,0 +1,16 @@
// A tiny in-memory signal used to focus the request URL bar right after a new request is created.
// We can't pass this through the router (the tab-navigation layer strips redirect search params),
// and a consume-on-render flag breaks under React StrictMode (the discarded first mount consumes it),
// so the create flow sets the flag, the URL bar reads it during render, and it's cleared only once the
// editor actually focuses itself (via OneLineEditor's onAutoFocus).
let pendingFocusUrlBar = false;
export const focusUrlBarOnNextRequest = () => {
pendingFocusUrlBar = true;
};
export const shouldFocusUrlBar = (): boolean => pendingFocusUrlBar;
export const clearPendingFocusUrlBar = () => {
pendingFocusUrlBar = false;
};

View File

@@ -17,6 +17,7 @@ import {
} from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.send';
import { OneLineEditor, type OneLineEditorHandle } from '~/ui/components/.client/codemirror/one-line-editor';
import { showSettingsModal } from '~/ui/components/modals/settings-modal';
import { clearPendingFocusUrlBar, shouldFocusUrlBar } from '~/ui/components/request-url-bar-focus';
import { recordProjectRecentRequest } from '~/ui/utils/recent-project-requests';
import { renderRealtimeConnectPayload } from '~/ui/utils/render-realtime-connect';
@@ -128,6 +129,11 @@ export const RequestUrlBar = forwardRef<RequestUrlBarHandle, Props>(
}
}, [inputRef]);
// Focus the URL bar when this mounts for a freshly created request (flag set by the create flow).
// Read (not consumed) during render; the editor clears the flag once it actually focuses, which is
// robust to React StrictMode's double-mount and to the request pane keying by requestId.
const focusUrlOnMount = shouldFocusUrlBar();
const setUrl = useCallback(
(url: string) => {
if (inputRef.current) {
@@ -313,6 +319,8 @@ export const RequestUrlBar = forwardRef<RequestUrlBarHandle, Props>(
uniquenessKey={uniquenessKey}
ref={inputRef}
type="text"
autoFocus={focusUrlOnMount}
onAutoFocus={clearPendingFocusUrlBar}
getAutocompleteConstants={handleAutocompleteUrls}
placeholder="https://api.myproduct.com/v1/users"
defaultValue={url}

View File

@@ -1092,6 +1092,41 @@ const ProjectNavigationSidebarInner = (
ref={parentRef}
className="group/tree flex-1 overflow-y-auto pb-(--padding-sm)"
data-testid="project-navigation-tree-container"
onKeyDownCapture={(e: React.KeyboardEvent) => {
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') {
return;
}
const active = document.activeElement;
if (!(active instanceof HTMLElement)) {
return;
}
// Only act when the row itself is focused, not an inner control (button/input).
const rowEl = active.closest('[data-key]');
if (!rowEl || rowEl !== active) {
return;
}
const docId = (active.dataset.key || '').replace(/^pinned-request-/, '');
const item = visibleFlatItems.find(i => i.doc._id === docId && i.kind !== 'pinnedRequest');
if (!item) {
return;
}
// ArrowRight expands a collapsed item; ArrowLeft collapses an expanded one.
const expand = e.key === 'ArrowRight';
const isExpandable =
(item.kind === 'collectionChild' && models.requestGroup.isRequestGroup(item.doc)) ||
item.kind === 'project' ||
item.kind === 'workspace';
if (!isExpandable || item.collapsed !== expand) {
return;
}
e.preventDefault();
e.stopPropagation();
if (item.kind === 'collectionChild') {
toggleRequestGroups([docId], item.workspace, !expand);
} else {
toggleProjectOrWorkspace(docId);
}
}}
>
<GridList
aria-label="Project Navigation Tree"