Skip to content

Commit 0557a68

Browse files
v6: Open schema search with Cmd+K and remove the dead top-bar button (#4402)
## Summary Schema search was unreachable. The top-bar "Jump to schema" button had no `onClick`, and the real shortcut (`⌘⌥K`) clicked a `.graphiql-sidebar` button selector that stopped existing once v6 renamed that container to `.graphiql-activity-rail`. The dead button is gone, and schema search is rebound to plain `⌘K` / `Ctrl+K`. The handler opens the doc explorer through `setVisiblePlugin` instead of clicking a DOM node, and it takes priority over monaco-editor's own `Cmd+K` binding, so it works whether or not an editor pane has focus. The shortcuts help dialog and key map are updated to match. ## Test plan - [x] Press `⌘K` (or `Ctrl+K`) with no editor focused. The doc explorer opens and the search input is focused. - [x] Click into the query editor, then press `⌘K`. The doc explorer still opens and search is focused; Monaco should not swallow the shortcut. - [x] Confirm the old "Jump to schema" button in the top bar is gone. - [x] Open the shortcuts help dialog (`?` or the help entry) and confirm "Search in documentation" lists `⌘K` with no Alt/Option. Refs: #4219
1 parent 83b79e3 commit 0557a68

10 files changed

Lines changed: 208 additions & 61 deletions

File tree

.changeset/schema-search-cmdk.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@graphiql/react': patch
3+
'@graphiql/plugin-doc-explorer': patch
4+
'graphiql': patch
5+
---
6+
7+
Schema search now opens with `Cmd/Ctrl+K`; removed the dead top-bar button that never opened anything.

packages/graphiql-plugin-doc-explorer/src/components/__tests__/doc-explorer.spec.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ vi.mock('@graphiql/react', async () => {
1818
return {
1919
...originalModule,
2020
useGraphiQL: vi.fn(),
21+
useGraphiQLActions: vi.fn(() => ({ setVisiblePlugin: vi.fn() })),
2122
};
2223
});
2324

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import { render } from '@testing-library/react';
3+
import { GraphQLObjectType, GraphQLSchema, GraphQLString } from 'graphql';
4+
import { type Mock } from 'vitest';
5+
import {
6+
useGraphiQL as $useGraphiQL,
7+
useGraphiQLActions as $useGraphiQLActions,
8+
isMacOs,
9+
} from '@graphiql/react';
10+
import { DOC_EXPLORER_PLUGIN, DocExplorerStore } from './context';
11+
12+
const useGraphiQL = $useGraphiQL as Mock;
13+
const useGraphiQLActions = $useGraphiQLActions as Mock;
14+
15+
vi.mock('@graphiql/react', async () => {
16+
const originalModule =
17+
await vi.importActual<typeof import('@graphiql/react')>('@graphiql/react');
18+
return {
19+
...originalModule,
20+
useGraphiQL: vi.fn(),
21+
useGraphiQLActions: vi.fn(),
22+
};
23+
});
24+
25+
const schema = new GraphQLSchema({
26+
query: new GraphQLObjectType({
27+
name: 'Query',
28+
fields: { field: { type: GraphQLString } },
29+
}),
30+
});
31+
32+
const setVisiblePlugin = vi.fn();
33+
34+
function setup() {
35+
useGraphiQL.mockImplementation(cb =>
36+
cb({ schema, validationErrors: [], schemaReference: null }),
37+
);
38+
useGraphiQLActions.mockReturnValue({ setVisiblePlugin });
39+
return render(
40+
<DocExplorerStore>
41+
<div />
42+
</DocExplorerStore>,
43+
);
44+
}
45+
46+
// `isMacOs` is derived from the test environment's user agent, so build the
47+
// keydown event using whichever modifier this handler actually expects.
48+
function dispatchSearchShortcut(extra: Partial<KeyboardEventInit> = {}) {
49+
const event = new KeyboardEvent('keydown', {
50+
code: 'KeyK',
51+
metaKey: isMacOs,
52+
ctrlKey: !isMacOs,
53+
bubbles: true,
54+
cancelable: true,
55+
...extra,
56+
});
57+
const preventDefaultSpy = vi.spyOn(event, 'preventDefault');
58+
window.dispatchEvent(event);
59+
return { event, preventDefaultSpy };
60+
}
61+
62+
describe('DocExplorerStore keyboard shortcut', () => {
63+
beforeEach(() => {
64+
vi.clearAllMocks();
65+
document.body.innerHTML = '';
66+
});
67+
68+
afterEach(() => {
69+
document.body.innerHTML = '';
70+
});
71+
72+
it('opens the doc explorer on plain Cmd/Ctrl+K, without requiring Alt', () => {
73+
setup();
74+
75+
dispatchSearchShortcut();
76+
77+
expect(setVisiblePlugin).toHaveBeenCalledWith(DOC_EXPLORER_PLUGIN);
78+
});
79+
80+
it('does not open the doc explorer for Alt+Cmd/Ctrl+K (old binding)', () => {
81+
setup();
82+
83+
dispatchSearchShortcut({ altKey: true });
84+
85+
// The old binding required Alt; the new one doesn't care either way,
86+
// so this should still fire. Guard against a regression back to
87+
// requiring Alt by asserting it fires with Alt held too.
88+
expect(setVisiblePlugin).toHaveBeenCalledWith(DOC_EXPLORER_PLUGIN);
89+
});
90+
91+
it('does nothing when the modifier key is missing', () => {
92+
setup();
93+
94+
dispatchSearchShortcut({ metaKey: false, ctrlKey: false });
95+
96+
expect(setVisiblePlugin).not.toHaveBeenCalled();
97+
});
98+
99+
it('focuses the search input after opening via Cmd/Ctrl+K', async () => {
100+
setup();
101+
102+
const searchInput = document.createElement('div');
103+
searchInput.className = 'graphiql-doc-explorer-search-row-input';
104+
const clickSpy = vi.fn();
105+
searchInput.addEventListener('click', clickSpy);
106+
document.body.append(searchInput);
107+
108+
dispatchSearchShortcut();
109+
110+
await vi.waitFor(() => {
111+
expect(clickSpy).toHaveBeenCalled();
112+
});
113+
});
114+
115+
it('prevents default so Monaco does not swallow the shortcut even with an editor focused', () => {
116+
setup();
117+
118+
const { preventDefaultSpy } = dispatchSearchShortcut();
119+
120+
expect(preventDefaultSpy).toHaveBeenCalled();
121+
expect(setVisiblePlugin).toHaveBeenCalledWith(DOC_EXPLORER_PLUGIN);
122+
});
123+
});

packages/graphiql-plugin-doc-explorer/src/context.tsx

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { FC, ReactElement, ReactNode, useEffect } from 'react';
1818
import {
1919
SchemaReference,
2020
useGraphiQL,
21+
useGraphiQLActions,
2122
pick,
2223
createBoundedUseStore,
2324
GraphiQLPlugin,
@@ -268,12 +269,33 @@ export const docExplorerStore = createStore<DocExplorerStoreType>(
268269
}),
269270
);
270271

272+
// The doc-explorer panel (and its search input) mounts asynchronously after
273+
// `setVisiblePlugin` commits, which isn't guaranteed to land within a single
274+
// animation frame, so poll across a few frames rather than assuming one is
275+
// enough.
276+
function focusSearchInputWhenMounted(framesLeft: number) {
277+
if (framesLeft <= 0) {
278+
return;
279+
}
280+
requestAnimationFrame(() => {
281+
const el = document.querySelector<HTMLDivElement>(
282+
'.graphiql-doc-explorer-search-row-input',
283+
);
284+
if (el) {
285+
el.click();
286+
} else {
287+
focusSearchInputWhenMounted(framesLeft - 1);
288+
}
289+
});
290+
}
291+
271292
export const DocExplorerStore: FC<{
272293
children: ReactNode;
273294
}> = ({ children }) => {
274295
const { schema, validationErrors, schemaReference } = useGraphiQL(
275296
pick('schema', 'validationErrors', 'schemaReference'),
276297
);
298+
const { setVisiblePlugin } = useGraphiQLActions();
277299

278300
useEffect(() => {
279301
const { resolveSchemaReferenceToNavItem } =
@@ -295,35 +317,31 @@ export const DocExplorerStore: FC<{
295317

296318
useEffect(() => {
297319
function handleKeyDown(event: KeyboardEvent) {
298-
const shouldFocusInput =
299-
// Use an additional `Alt` key instead of `Cmd/Ctrl+K` because monaco-editor has a built-in
300-
// shortcut for `Cmd/Ctrl+K`
301-
event.altKey &&
320+
const shouldOpenSearch =
302321
event[isMacOs ? 'metaKey' : 'ctrlKey'] &&
303322
// Using `event.code` because `event.key` will trigger different character
304323
// in English `˚` and in French `È`
305324
event.code === 'KeyK';
306-
if (!shouldFocusInput) {
325+
if (!shouldOpenSearch) {
307326
return;
308327
}
309-
const button = document.querySelector<HTMLButtonElement>(
310-
'.graphiql-sidebar button[aria-label="Show Documentation Explorer"]',
311-
);
312-
button?.click();
313-
// Execute on next tick when doc explorer is opened and input exists in DOM
314-
requestAnimationFrame(() => {
315-
const el = document.querySelector<HTMLDivElement>(
316-
'.graphiql-doc-explorer-search-input',
317-
);
318-
el?.click();
319-
});
328+
// Take priority over monaco-editor's built-in `Cmd/Ctrl+K` binding even
329+
// when an editor pane has focus.
330+
event.preventDefault();
331+
event.stopPropagation();
332+
333+
setVisiblePlugin(DOC_EXPLORER_PLUGIN);
334+
// The panel (and its search input) mounts after this state update
335+
// commits, which isn't guaranteed to land within a single animation
336+
// frame, so poll across a few frames rather than assuming one is enough.
337+
focusSearchInputWhenMounted(10);
320338
}
321339

322-
window.addEventListener('keydown', handleKeyDown);
340+
window.addEventListener('keydown', handleKeyDown, true);
323341
return () => {
324-
window.removeEventListener('keydown', handleKeyDown);
342+
window.removeEventListener('keydown', handleKeyDown, true);
325343
};
326-
}, []);
344+
}, [setVisiblePlugin]);
327345

328346
return children as ReactElement;
329347
};

packages/graphiql-react/src/components/top-bar/index.css

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -44,22 +44,6 @@
4444
flex-shrink: 0;
4545
}
4646

47-
.graphiql-top-bar-cmd {
48-
display: inline-flex;
49-
align-items: center;
50-
gap: var(--px-6);
51-
padding: 0 var(--px-10);
52-
height: 24px;
53-
background: oklch(var(--bg-subtle));
54-
border: 1px solid oklch(var(--border-strong));
55-
border-radius: var(--radius-md);
56-
color: oklch(var(--fg-subtle));
57-
font-family: var(--font-family-mono);
58-
font-size: var(--font-size-small);
59-
cursor: pointer;
60-
white-space: nowrap;
61-
}
62-
6347
.graphiql-top-bar-endpoint {
6448
display: inline-flex;
6549
align-items: center;

packages/graphiql-react/src/components/top-bar/index.tsx

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -129,14 +129,6 @@ export const TopBarView: FC<TopBarViewProps> = ({
129129
<span className="graphiql-top-bar-endpoint-url">{url}</span>
130130
</div>
131131

132-
<button type="button" className="graphiql-top-bar-cmd">
133-
<span>Jump to schema</span>
134-
<KeycapHint
135-
keys={[MODIFIER.Meta, 'K']}
136-
ariaLabel="Open command palette"
137-
/>
138-
</button>
139-
140132
{isBlocked ? (
141133
<Tooltip label={runDisabledReason}>
142134
{/* A native disabled button emits no pointer/focus events, so Radix

packages/graphiql-react/src/components/top-bar/top-bar.test.tsx

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -232,11 +232,6 @@ describe('TopBarView', () => {
232232
).toBeNull();
233233
});
234234

235-
it('renders the command palette button', () => {
236-
render(<TopBarView {...DEFAULTS} />);
237-
expect(screen.getByText('Jump to schema')).toBeInTheDocument();
238-
});
239-
240235
it('has role="banner" on the header element', () => {
241236
const { container } = render(<TopBarView {...DEFAULTS} />);
242237
expect(container.querySelector('header[role="banner"]')).not.toBeNull();

packages/graphiql-react/src/constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export const KEY_MAP = Object.freeze({
4242
key: 'Ctrl-F',
4343
},
4444
searchInDocs: {
45-
key: 'Ctrl-Alt-K',
45+
key: 'Ctrl-K',
4646
},
4747
});
4848

packages/graphiql/src/GraphiQL.spec.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
useGraphiQL,
1919
useOperationsEditorState,
2020
MonacoEditor,
21+
isMacOs,
2122
} from '@graphiql/react';
2223
import '@graphiql/react/setup-workers/vite';
2324

@@ -253,6 +254,34 @@ describe('GraphiQL', () => {
253254
expect(container.querySelector('.graphiql-plugin')).not.toBeVisible();
254255
});
255256
});
257+
258+
it('reveals the plugin pane when Cmd/Ctrl+K opens the doc explorer', async () => {
259+
const { container } = render(<GraphiQL fetcher={noOpFetcher} />);
260+
261+
const pane = container.querySelector('.graphiql-plugin');
262+
await waitFor(() => {
263+
expect(pane).not.toBeVisible();
264+
});
265+
266+
act(() => {
267+
window.dispatchEvent(
268+
new KeyboardEvent('keydown', {
269+
code: 'KeyK',
270+
metaKey: isMacOs,
271+
ctrlKey: !isMacOs,
272+
bubbles: true,
273+
cancelable: true,
274+
}),
275+
);
276+
});
277+
278+
await waitFor(() => {
279+
expect(pane).toBeVisible();
280+
expect(
281+
container.querySelector('.graphiql-doc-explorer'),
282+
).toBeInTheDocument();
283+
});
284+
});
256285
}); // plugins
257286

258287
describe('editor tools', () => {

packages/graphiql/src/GraphiQL.tsx

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type {
1010
FC,
1111
ComponentPropsWithoutRef,
1212
} from 'react';
13-
import { Children, useRef, useState, Fragment } from 'react';
13+
import { Children, useEffect, useRef, useState, Fragment } from 'react';
1414
import {
1515
ChevronDownIcon,
1616
ChevronUpIcon,
@@ -367,11 +367,16 @@ export const GraphiQLInterface: FC<GraphiQLInterfaceProps> = ({
367367
},
368368
);
369369

370-
function onClickReference() {
371-
if (pluginHiddenElement === 'first') {
370+
// `visiblePlugin` and the pane's collapsed state are separate: the store
371+
// tracks which plugin is active, while `useDragResize` owns the pane width.
372+
// Reveal the pane whenever a plugin becomes visible through any path that
373+
// doesn't manage the drag-resize state itself (the ⌘K shortcut, the
374+
// `visiblePlugin` prop, or a plugin calling `setVisiblePlugin` directly).
375+
useEffect(() => {
376+
if (visiblePlugin && pluginHiddenElement === 'first') {
372377
setPluginHiddenElement(null);
373378
}
374-
}
379+
}, [visiblePlugin, pluginHiddenElement, setPluginHiddenElement]);
375380

376381
const toggleEditorTools: ButtonHandler = () => {
377382
setEditorToolsHiddenElement(
@@ -409,14 +414,7 @@ export const GraphiQLInterface: FC<GraphiQLInterfaceProps> = ({
409414
aria-label="Operation Editor"
410415
ref={editorToolsFirstRef}
411416
>
412-
{hasMonaco ? (
413-
<QueryEditor
414-
onClickReference={onClickReference}
415-
onEdit={onEditQuery}
416-
/>
417-
) : (
418-
<Spinner />
419-
)}
417+
{hasMonaco ? <QueryEditor onEdit={onEditQuery} /> : <Spinner />}
420418

421419
<div
422420
className="graphiql-toolbar"

0 commit comments

Comments
 (0)