Skip to content

Commit 16b66b3

Browse files
GregBrimbleclaude
andauthored
Add keyboardShortcuts property to allow users to customize shortcuts (#2741)
Address PR feedback: remove docs changes and update bundle size limits - Remove documentation changes from api.mdx (not needed in v3 backport) - Update bundlesize.config.json limits: - docsearch-react: 27 kB → 27.5 kB - docsearch-js: 35.5 kB → 36 kB 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Claude <[email protected]>
1 parent 85568ce commit 16b66b3

File tree

8 files changed

+231
-12
lines changed

8 files changed

+231
-12
lines changed

bundlesize.config.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@
66
},
77
{
88
"path": "packages/docsearch-react/dist/umd/index.js",
9-
"maxSize": "27 kB"
9+
"maxSize": "27.5 kB"
1010
},
1111
{
1212
"path": "packages/docsearch-js/dist/umd/index.js",
13-
"maxSize": "35.5 kB"
13+
"maxSize": "36 kB"
1414
}
1515
]
1616
}

packages/docsearch-react/src/DocSearch.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@ import { createPortal } from 'react-dom';
55

66
import { DocSearchButton } from './DocSearchButton';
77
import { DocSearchModal } from './DocSearchModal';
8-
import type { DocSearchHit, DocSearchTheme, InternalDocSearchHit, StoredDocSearchHit } from './types';
8+
import type {
9+
DocSearchHit,
10+
DocSearchTheme,
11+
InternalDocSearchHit,
12+
KeyboardShortcuts,
13+
StoredDocSearchHit,
14+
} from './types';
915
import { useDocSearchKeyboardEvents } from './useDocSearchKeyboardEvents';
1016
import { useTheme } from './useTheme';
1117

@@ -41,6 +47,7 @@ export interface DocSearchProps {
4147
translations?: DocSearchTranslations;
4248
getMissingResultsUrl?: ({ query }: { query: string }) => string;
4349
insights?: AutocompleteOptions<InternalDocSearchHit>['insights'];
50+
keyboardShortcuts?: KeyboardShortcuts;
4451
}
4552

4653
export function DocSearch({ ...props }: DocSearchProps): JSX.Element {
@@ -71,12 +78,18 @@ export function DocSearch({ ...props }: DocSearchProps): JSX.Element {
7178
onClose,
7279
onInput,
7380
searchButtonRef,
81+
keyboardShortcuts: props.keyboardShortcuts,
7482
});
7583
useTheme({ theme: props.theme });
7684

7785
return (
7886
<>
79-
<DocSearchButton ref={searchButtonRef} translations={props?.translations?.button} onClick={onOpen} />
87+
<DocSearchButton
88+
ref={searchButtonRef}
89+
translations={props?.translations?.button}
90+
keyboardShortcuts={props.keyboardShortcuts}
91+
onClick={onOpen}
92+
/>
8093

8194
{isOpen &&
8295
createPortal(

packages/docsearch-react/src/DocSearchButton.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import React, { useEffect, useState, type JSX } from 'react';
22

3+
import { getKeyboardShortcuts } from './constants/keyboardShortcuts';
34
import { ControlKeyIcon } from './icons/ControlKeyIcon';
45
import { SearchIcon } from './icons/SearchIcon';
5-
import type { DocSearchTheme } from './types';
6+
import type { DocSearchTheme, KeyboardShortcuts } from './types';
67
import { useTheme } from './useTheme';
78

89
export type ButtonTranslations = Partial<{
@@ -13,6 +14,7 @@ export type ButtonTranslations = Partial<{
1314
export type DocSearchButtonProps = React.ComponentProps<'button'> & {
1415
theme?: DocSearchTheme;
1516
translations?: ButtonTranslations;
17+
keyboardShortcuts?: KeyboardShortcuts;
1618
};
1719

1820
const ACTION_KEY_DEFAULT = 'Ctrl' as const;
@@ -23,8 +25,9 @@ function isAppleDevice(): boolean {
2325
}
2426

2527
export const DocSearchButton = React.forwardRef<HTMLButtonElement, DocSearchButtonProps>(
26-
({ translations = {}, ...props }, ref) => {
28+
({ translations = {}, keyboardShortcuts, ...props }, ref) => {
2729
const { buttonText = 'Search', buttonAriaLabel = 'Search' } = translations;
30+
const resolvedShortcuts = getKeyboardShortcuts(keyboardShortcuts);
2831

2932
const [key, setKey] = useState<typeof ACTION_KEY_APPLE | typeof ACTION_KEY_DEFAULT | null>(null);
3033
useTheme({ theme: props.theme });
@@ -40,14 +43,15 @@ export const DocSearchButton = React.forwardRef<HTMLButtonElement, DocSearchButt
4043
([ACTION_KEY_DEFAULT, 'Control', <ControlKeyIcon />] as const)
4144
: (['Meta', 'Meta', key] as const);
4245

46+
const isCtrlCmdKEnabled = resolvedShortcuts['Ctrl/Cmd+K'];
4347
const shortcut = `${actionKeyAltText}+k`;
4448

4549
return (
4650
<button
4751
type="button"
4852
className="DocSearch DocSearch-Button"
49-
aria-label={`${buttonAriaLabel} (${shortcut})`}
50-
aria-keyshortcuts={shortcut}
53+
aria-label={isCtrlCmdKEnabled ? `${buttonAriaLabel} (${shortcut})` : buttonAriaLabel}
54+
aria-keyshortcuts={isCtrlCmdKEnabled ? shortcut : undefined}
5155
{...props}
5256
ref={ref}
5357
>
@@ -57,7 +61,7 @@ export const DocSearchButton = React.forwardRef<HTMLButtonElement, DocSearchButt
5761
</span>
5862

5963
<span className="DocSearch-Button-Keys">
60-
{key !== null && (
64+
{key !== null && isCtrlCmdKEnabled && (
6165
<>
6266
<DocSearchButtonKey reactsToKey={actionKeyReactsTo}>{actionKeyChild}</DocSearchButtonKey>
6367
<DocSearchButtonKey reactsToKey="k">K</DocSearchButtonKey>
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { render, act, fireEvent, screen, cleanup } from '@testing-library/react';
2+
import React, { type JSX } from 'react';
3+
import { describe, it, expect, afterEach } from 'vitest';
4+
5+
import '@testing-library/jest-dom/vitest';
6+
7+
import { DocSearch as DocSearchComponent } from '../DocSearch';
8+
import type { DocSearchProps } from '../DocSearch';
9+
10+
function DocSearch(props: Partial<DocSearchProps>): JSX.Element {
11+
return <DocSearchComponent appId="woo" apiKey="foo" indexName="bar" {...props} />;
12+
}
13+
14+
describe('keyboard shortcuts', () => {
15+
afterEach(() => {
16+
cleanup();
17+
});
18+
19+
describe('default behavior', () => {
20+
it('shows Ctrl/Cmd+K shortcut hint by default', () => {
21+
render(<DocSearch />);
22+
23+
const button = document.querySelector('.DocSearch-Button');
24+
expect(button).toBeInTheDocument();
25+
expect(button?.getAttribute('aria-label')).toMatch(/\(Control\+k\)/);
26+
expect(document.querySelector('.DocSearch-Button-Keys')).toBeInTheDocument();
27+
});
28+
29+
it('responds to Ctrl+K keyboard shortcut by default', () => {
30+
render(<DocSearch />);
31+
32+
expect(document.querySelector('.DocSearch-Modal')).not.toBeInTheDocument();
33+
34+
act(() => {
35+
fireEvent.keyDown(document, { key: 'k', ctrlKey: true });
36+
});
37+
38+
expect(document.querySelector('.DocSearch-Modal')).toBeInTheDocument();
39+
});
40+
41+
it('responds to / keyboard shortcut by default', () => {
42+
render(<DocSearch />);
43+
44+
expect(document.querySelector('.DocSearch-Modal')).not.toBeInTheDocument();
45+
46+
act(() => {
47+
fireEvent.keyDown(document, { key: '/' });
48+
});
49+
50+
expect(document.querySelector('.DocSearch-Modal')).toBeInTheDocument();
51+
});
52+
});
53+
54+
describe('custom keyboard shortcuts configuration', () => {
55+
it('hides shortcut hint when Ctrl/Cmd+K is disabled', () => {
56+
render(<DocSearch keyboardShortcuts={{ 'Ctrl/Cmd+K': false }} />);
57+
58+
const button = document.querySelector('.DocSearch-Button');
59+
expect(button).toBeInTheDocument();
60+
expect(button?.getAttribute('aria-label')).toBe('Search');
61+
expect(document.querySelector('.DocSearch-Button-Keys')).toBeInTheDocument();
62+
expect(document.querySelector('.DocSearch-Button-Key')).not.toBeInTheDocument();
63+
});
64+
65+
it('does not respond to Ctrl+K when disabled', () => {
66+
render(<DocSearch keyboardShortcuts={{ 'Ctrl/Cmd+K': false }} />);
67+
68+
expect(document.querySelector('.DocSearch-Modal')).not.toBeInTheDocument();
69+
70+
act(() => {
71+
fireEvent.keyDown(document, { key: 'k', ctrlKey: true });
72+
});
73+
74+
expect(document.querySelector('.DocSearch-Modal')).not.toBeInTheDocument();
75+
});
76+
77+
it('does not respond to / when disabled', () => {
78+
render(<DocSearch keyboardShortcuts={{ '/': false }} />);
79+
80+
expect(document.querySelector('.DocSearch-Modal')).not.toBeInTheDocument();
81+
82+
act(() => {
83+
fireEvent.keyDown(document, { key: '/' });
84+
});
85+
86+
expect(document.querySelector('.DocSearch-Modal')).not.toBeInTheDocument();
87+
});
88+
89+
it('still shows shortcut hint when only / is disabled', () => {
90+
render(<DocSearch keyboardShortcuts={{ '/': false }} />);
91+
92+
const button = document.querySelector('.DocSearch-Button');
93+
expect(button).toBeInTheDocument();
94+
expect(button?.getAttribute('aria-label')).toMatch(/\(Control\+k\)/);
95+
expect(document.querySelector('.DocSearch-Button-Key')).toBeInTheDocument();
96+
});
97+
98+
it('responds to enabled shortcuts when others are disabled', () => {
99+
render(<DocSearch keyboardShortcuts={{ '/': false }} />);
100+
101+
expect(document.querySelector('.DocSearch-Modal')).not.toBeInTheDocument();
102+
103+
act(() => {
104+
fireEvent.keyDown(document, { key: 'k', ctrlKey: true });
105+
});
106+
107+
expect(document.querySelector('.DocSearch-Modal')).toBeInTheDocument();
108+
});
109+
110+
it('can disable all shortcuts', () => {
111+
render(<DocSearch keyboardShortcuts={{ 'Ctrl/Cmd+K': false, '/': false }} />);
112+
113+
const button = document.querySelector('.DocSearch-Button');
114+
expect(button?.getAttribute('aria-label')).toBe('Search');
115+
expect(document.querySelector('.DocSearch-Button-Key')).not.toBeInTheDocument();
116+
117+
expect(document.querySelector('.DocSearch-Modal')).not.toBeInTheDocument();
118+
119+
act(() => {
120+
fireEvent.keyDown(document, { key: 'k', ctrlKey: true });
121+
});
122+
123+
expect(document.querySelector('.DocSearch-Modal')).not.toBeInTheDocument();
124+
125+
act(() => {
126+
fireEvent.keyDown(document, { key: '/' });
127+
});
128+
129+
expect(document.querySelector('.DocSearch-Modal')).not.toBeInTheDocument();
130+
});
131+
});
132+
133+
describe('Escape key behavior', () => {
134+
it('always responds to Escape to close modal regardless of configuration', async () => {
135+
render(<DocSearch keyboardShortcuts={{ 'Ctrl/Cmd+K': false, '/': false }} />);
136+
137+
// Open modal via button click
138+
await act(async () => {
139+
fireEvent.click(await screen.findByText('Search'));
140+
});
141+
142+
expect(document.querySelector('.DocSearch-Modal')).toBeInTheDocument();
143+
144+
// Close with Escape
145+
act(() => {
146+
fireEvent.keyDown(document, { code: 'Escape' });
147+
});
148+
149+
expect(document.querySelector('.DocSearch-Modal')).not.toBeInTheDocument();
150+
});
151+
});
152+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { KeyboardShortcuts } from '../types';
2+
3+
/**
4+
* Default keyboard shortcuts configuration for DocSearch.
5+
* These values are used when no keyboardShortcuts prop is provided
6+
* or when specific shortcuts are not configured.
7+
*/
8+
export const DEFAULT_KEYBOARD_SHORTCUTS: Required<KeyboardShortcuts> = {
9+
'Ctrl/Cmd+K': true,
10+
'/': true,
11+
} as const;
12+
13+
/**
14+
* Merges user-provided keyboard shortcuts with defaults.
15+
*
16+
* @param userShortcuts - Optional user configuration.
17+
* @returns Complete keyboard shortcuts configuration with defaults applied.
18+
*/
19+
export function getKeyboardShortcuts(userShortcuts?: KeyboardShortcuts): Required<KeyboardShortcuts> {
20+
return {
21+
...DEFAULT_KEYBOARD_SHORTCUTS,
22+
...userShortcuts,
23+
};
24+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export interface KeyboardShortcuts {
2+
/**
3+
* Enable/disable the Ctrl/Cmd+K shortcut to toggle the search modal.
4+
*
5+
* @default true
6+
*/
7+
'Ctrl/Cmd+K'?: boolean;
8+
/**
9+
* Enable/disable the / shortcut to open the search modal.
10+
*
11+
* @default true
12+
*/
13+
'/'?: boolean;
14+
}

packages/docsearch-react/src/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@ export * from './DocSearchHit';
22
export * from './DocSearchState';
33
export * from './DocSearchTheme';
44
export * from './InternalDocSearchHit';
5+
export * from './KeyboardShortcuts';
56
export * from './StoredDocSearchHit';

packages/docsearch-react/src/useDocSearchKeyboardEvents.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import React from 'react';
22

3+
import { getKeyboardShortcuts } from './constants/keyboardShortcuts';
4+
import type { KeyboardShortcuts } from './types';
5+
36
export interface UseDocSearchKeyboardEventsProps {
47
isOpen: boolean;
58
onOpen: () => void;
69
onClose: () => void;
710
onInput?: (event: KeyboardEvent) => void;
811
searchButtonRef: React.RefObject<HTMLButtonElement | null>;
12+
keyboardShortcuts?: KeyboardShortcuts;
913
}
1014

1115
function isEditingContent(event: KeyboardEvent): boolean {
@@ -21,19 +25,26 @@ export function useDocSearchKeyboardEvents({
2125
onClose,
2226
onInput,
2327
searchButtonRef,
28+
keyboardShortcuts,
2429
}: UseDocSearchKeyboardEventsProps): void {
30+
const resolvedShortcuts = getKeyboardShortcuts(keyboardShortcuts);
31+
2532
React.useEffect(() => {
2633
function onKeyDown(event: KeyboardEvent): void {
34+
const isCmdK =
35+
resolvedShortcuts['Ctrl/Cmd+K'] && event.key?.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey);
36+
const isSlash = resolvedShortcuts['/'] && event.key === '/';
37+
2738
if (
2839
(event.code === 'Escape' && isOpen) ||
2940
// The `Cmd+K` shortcut both opens and closes the modal.
3041
// We need to check for `event.key` because it can be `undefined` with
3142
// Chrome's autofill feature.
3243
// See https://github.com/paperjs/paper.js/issues/1398
33-
(event.key?.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) ||
44+
isCmdK ||
3445
// The `/` shortcut opens but doesn't close the modal because it's
3546
// a character.
36-
(!isEditingContent(event) && event.key === '/' && !isOpen)
47+
(!isEditingContent(event) && isSlash && !isOpen)
3748
) {
3849
event.preventDefault();
3950

@@ -60,5 +71,5 @@ export function useDocSearchKeyboardEvents({
6071
return (): void => {
6172
window.removeEventListener('keydown', onKeyDown);
6273
};
63-
}, [isOpen, onOpen, onClose, onInput, searchButtonRef]);
74+
}, [isOpen, onOpen, onClose, onInput, searchButtonRef, resolvedShortcuts]);
6475
}

0 commit comments

Comments
 (0)