1
0
Fork 0

Add keyboardShortcuts property to allow users to customize shortcuts (#2740)

Co-authored-by: Paul Jankowski <33367713+8bittitan@users.noreply.github.com>
This commit is contained in:
Greg Brimble 2025-09-04 09:05:08 -04:00 committed by GitHub
parent d82fb7cc65
commit 5be3e846a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 324 additions and 10 deletions

View file

@ -5,7 +5,13 @@ import { createPortal } from 'react-dom';
import { DocSearchButton } from './DocSearchButton';
import { DocSearchModal } from './DocSearchModal';
import type { DocSearchHit, DocSearchTheme, InternalDocSearchHit, StoredDocSearchHit } from './types';
import type {
DocSearchHit,
DocSearchTheme,
InternalDocSearchHit,
KeyboardShortcuts,
StoredDocSearchHit,
} from './types';
import { useDocSearchKeyboardEvents } from './useDocSearchKeyboardEvents';
import { useTheme } from './useTheme';
@ -155,6 +161,10 @@ export interface DocSearchProps {
* @default 4
*/
recentSearchesWithFavoritesLimit?: number;
/**
* Configuration for keyboard shortcuts. Allows enabling/disabling specific shortcuts.
*/
keyboardShortcuts?: KeyboardShortcuts;
}
export function DocSearch({ indexName, searchParameters, indices = [], ...props }: DocSearchProps): JSX.Element {
@ -212,6 +222,7 @@ export function DocSearch({ indexName, searchParameters, indices = [], ...props
isAskAiActive,
onAskAiToggle,
searchButtonRef,
keyboardShortcuts: props.keyboardShortcuts,
});
useTheme({ theme: props.theme });
@ -237,7 +248,12 @@ export function DocSearch({ indexName, searchParameters, indices = [], ...props
return (
<>
<DocSearchButton ref={searchButtonRef} translations={props?.translations?.button} onClick={onOpen} />
<DocSearchButton
ref={searchButtonRef}
translations={props?.translations?.button}
keyboardShortcuts={props.keyboardShortcuts}
onClick={onOpen}
/>
{isOpen &&
createPortal(

View file

@ -1,8 +1,9 @@
import React, { useEffect, useState, type JSX } from 'react';
import { getKeyboardShortcuts } from './constants/keyboardShortcuts';
import { ControlKeyIcon, KKeyIcon, MetaKeyIcon } from './icons/MetaKeysIcon';
import { SearchIcon } from './icons/SearchIcon';
import type { DocSearchTheme } from './types';
import type { DocSearchTheme, KeyboardShortcuts } from './types';
import { useTheme } from './useTheme';
export type ButtonTranslations = Partial<{
@ -13,6 +14,7 @@ export type ButtonTranslations = Partial<{
export type DocSearchButtonProps = React.ComponentProps<'button'> & {
theme?: DocSearchTheme;
translations?: ButtonTranslations;
keyboardShortcuts?: KeyboardShortcuts;
};
const ACTION_KEY_DEFAULT = 'Ctrl' as const;
@ -23,8 +25,9 @@ function isAppleDevice(): boolean {
}
export const DocSearchButton = React.forwardRef<HTMLButtonElement, DocSearchButtonProps>(
({ translations = {}, ...props }, ref) => {
({ translations = {}, keyboardShortcuts, ...props }, ref) => {
const { buttonText = 'Search', buttonAriaLabel = 'Search' } = translations;
const resolvedShortcuts = getKeyboardShortcuts(keyboardShortcuts);
const [key, setKey] = useState<typeof ACTION_KEY_APPLE | typeof ACTION_KEY_DEFAULT | null>(null);
useTheme({ theme: props.theme });
@ -41,13 +44,15 @@ export const DocSearchButton = React.forwardRef<HTMLButtonElement, DocSearchButt
: // eslint-disable-next-line react/jsx-key -- false flag
(['Meta', 'Meta', <MetaKeyIcon />] as const);
const isCtrlCmdKEnabled = resolvedShortcuts['Ctrl/Cmd+K'];
const shortcut = `${actionKeyAltText}+k`;
return (
<button
type="button"
className="DocSearch DocSearch-Button"
aria-label={`${buttonAriaLabel} (${shortcut})`}
aria-keyshortcuts={shortcut}
aria-label={isCtrlCmdKEnabled ? `${buttonAriaLabel} (${shortcut})` : buttonAriaLabel}
aria-keyshortcuts={isCtrlCmdKEnabled ? shortcut : undefined}
{...props}
ref={ref}
>
@ -57,7 +62,7 @@ export const DocSearchButton = React.forwardRef<HTMLButtonElement, DocSearchButt
</span>
<span className="DocSearch-Button-Keys">
{key !== null && (
{key !== null && isCtrlCmdKEnabled && (
<>
<DocSearchButtonKey reactsToKey={actionKeyReactsTo}>{actionKeyChild}</DocSearchButtonKey>
<DocSearchButtonKey reactsToKey="k">

View file

@ -0,0 +1,152 @@
import { render, act, fireEvent, screen, cleanup } from '@testing-library/react';
import React, { type JSX } from 'react';
import { describe, it, expect, afterEach } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { DocSearch as DocSearchComponent } from '../DocSearch';
import type { DocSearchProps } from '../DocSearch';
function DocSearch(props: Partial<DocSearchProps>): JSX.Element {
return <DocSearchComponent appId="woo" apiKey="foo" indexName="bar" {...props} />;
}
describe('keyboard shortcuts', () => {
afterEach(() => {
cleanup();
});
describe('default behavior', () => {
it('shows Ctrl/Cmd+K shortcut hint by default', () => {
render(<DocSearch />);
const button = document.querySelector('.DocSearch-Button');
expect(button).toBeInTheDocument();
expect(button?.getAttribute('aria-label')).toMatch(/\(Control\+k\)/);
expect(document.querySelector('.DocSearch-Button-Keys')).toBeInTheDocument();
});
it('responds to Ctrl+K keyboard shortcut by default', () => {
render(<DocSearch />);
expect(document.querySelector('.DocSearch-Modal')).not.toBeInTheDocument();
act(() => {
fireEvent.keyDown(document, { key: 'k', ctrlKey: true });
});
expect(document.querySelector('.DocSearch-Modal')).toBeInTheDocument();
});
it('responds to / keyboard shortcut by default', () => {
render(<DocSearch />);
expect(document.querySelector('.DocSearch-Modal')).not.toBeInTheDocument();
act(() => {
fireEvent.keyDown(document, { key: '/' });
});
expect(document.querySelector('.DocSearch-Modal')).toBeInTheDocument();
});
});
describe('custom keyboard shortcuts configuration', () => {
it('hides shortcut hint when Ctrl/Cmd+K is disabled', () => {
render(<DocSearch keyboardShortcuts={{ 'Ctrl/Cmd+K': false }} />);
const button = document.querySelector('.DocSearch-Button');
expect(button).toBeInTheDocument();
expect(button?.getAttribute('aria-label')).toBe('Search');
expect(document.querySelector('.DocSearch-Button-Keys')).toBeInTheDocument();
expect(document.querySelector('.DocSearch-Button-Key')).not.toBeInTheDocument();
});
it('does not respond to Ctrl+K when disabled', () => {
render(<DocSearch keyboardShortcuts={{ 'Ctrl/Cmd+K': false }} />);
expect(document.querySelector('.DocSearch-Modal')).not.toBeInTheDocument();
act(() => {
fireEvent.keyDown(document, { key: 'k', ctrlKey: true });
});
expect(document.querySelector('.DocSearch-Modal')).not.toBeInTheDocument();
});
it('does not respond to / when disabled', () => {
render(<DocSearch keyboardShortcuts={{ '/': false }} />);
expect(document.querySelector('.DocSearch-Modal')).not.toBeInTheDocument();
act(() => {
fireEvent.keyDown(document, { key: '/' });
});
expect(document.querySelector('.DocSearch-Modal')).not.toBeInTheDocument();
});
it('still shows shortcut hint when only / is disabled', () => {
render(<DocSearch keyboardShortcuts={{ '/': false }} />);
const button = document.querySelector('.DocSearch-Button');
expect(button).toBeInTheDocument();
expect(button?.getAttribute('aria-label')).toMatch(/\(Control\+k\)/);
expect(document.querySelector('.DocSearch-Button-Key')).toBeInTheDocument();
});
it('responds to enabled shortcuts when others are disabled', () => {
render(<DocSearch keyboardShortcuts={{ '/': false }} />);
expect(document.querySelector('.DocSearch-Modal')).not.toBeInTheDocument();
act(() => {
fireEvent.keyDown(document, { key: 'k', ctrlKey: true });
});
expect(document.querySelector('.DocSearch-Modal')).toBeInTheDocument();
});
it('can disable all shortcuts', () => {
render(<DocSearch keyboardShortcuts={{ 'Ctrl/Cmd+K': false, '/': false }} />);
const button = document.querySelector('.DocSearch-Button');
expect(button?.getAttribute('aria-label')).toBe('Search');
expect(document.querySelector('.DocSearch-Button-Key')).not.toBeInTheDocument();
expect(document.querySelector('.DocSearch-Modal')).not.toBeInTheDocument();
act(() => {
fireEvent.keyDown(document, { key: 'k', ctrlKey: true });
});
expect(document.querySelector('.DocSearch-Modal')).not.toBeInTheDocument();
act(() => {
fireEvent.keyDown(document, { key: '/' });
});
expect(document.querySelector('.DocSearch-Modal')).not.toBeInTheDocument();
});
});
describe('Escape key behavior', () => {
it('always responds to Escape to close modal regardless of configuration', async () => {
render(<DocSearch keyboardShortcuts={{ 'Ctrl/Cmd+K': false, '/': false }} />);
// Open modal via button click
await act(async () => {
fireEvent.click(await screen.findByText('Search'));
});
expect(document.querySelector('.DocSearch-Modal')).toBeInTheDocument();
// Close with Escape
act(() => {
fireEvent.keyDown(document, { code: 'Escape' });
});
expect(document.querySelector('.DocSearch-Modal')).not.toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,24 @@
import type { KeyboardShortcuts } from '../types';
/**
* Default keyboard shortcuts configuration for DocSearch.
* These values are used when no keyboardShortcuts prop is provided
* or when specific shortcuts are not configured.
*/
export const DEFAULT_KEYBOARD_SHORTCUTS: Required<KeyboardShortcuts> = {
'Ctrl/Cmd+K': true,
'/': true,
} as const;
/**
* Merges user-provided keyboard shortcuts with defaults.
*
* @param userShortcuts - Optional user configuration.
* @returns Complete keyboard shortcuts configuration with defaults applied.
*/
export function getKeyboardShortcuts(userShortcuts?: KeyboardShortcuts): Required<KeyboardShortcuts> {
return {
...DEFAULT_KEYBOARD_SHORTCUTS,
...userShortcuts,
};
}

View file

@ -0,0 +1,14 @@
export interface KeyboardShortcuts {
/**
* Enable/disable the Ctrl/Cmd+K shortcut to toggle the search modal.
*
* @default true
*/
'Ctrl/Cmd+K'?: boolean;
/**
* Enable/disable the / shortcut to open the search modal.
*
* @default true
*/
'/'?: boolean;
}

View file

@ -2,4 +2,5 @@ export * from './DocSearchHit';
export * from './DocSearchState';
export * from './DocSearchTheme';
export * from './InternalDocSearchHit';
export * from './KeyboardShortcuts';
export * from './StoredDocSearchHit';

View file

@ -1,5 +1,8 @@
import React from 'react';
import { getKeyboardShortcuts } from './constants/keyboardShortcuts';
import type { KeyboardShortcuts } from './types';
export interface UseDocSearchKeyboardEventsProps {
isOpen: boolean;
onOpen: () => void;
@ -8,6 +11,7 @@ export interface UseDocSearchKeyboardEventsProps {
searchButtonRef: React.RefObject<HTMLButtonElement | null>;
isAskAiActive: boolean;
onAskAiToggle: (toggle: boolean) => void;
keyboardShortcuts?: KeyboardShortcuts;
}
function isEditingContent(event: KeyboardEvent): boolean {
@ -25,7 +29,10 @@ export function useDocSearchKeyboardEvents({
isAskAiActive,
onAskAiToggle,
searchButtonRef,
keyboardShortcuts,
}: UseDocSearchKeyboardEventsProps): void {
const resolvedShortcuts = getKeyboardShortcuts(keyboardShortcuts);
React.useEffect(() => {
function onKeyDown(event: KeyboardEvent): void {
if (isOpen && event.code === 'Escape' && isAskAiActive) {
@ -33,16 +40,20 @@ export function useDocSearchKeyboardEvents({
return;
}
const isCmdK =
resolvedShortcuts['Ctrl/Cmd+K'] && event.key?.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey);
const isSlash = resolvedShortcuts['/'] && event.key === '/';
if (
(event.code === 'Escape' && isOpen) ||
// The `Cmd+K` shortcut both opens and closes the modal.
// We need to check for `event.key` because it can be `undefined` with
// Chrome's autofill feature.
// See https://github.com/paperjs/paper.js/issues/1398
(event.key?.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) ||
isCmdK ||
// The `/` shortcut opens but doesn't close the modal because it's
// a character.
(!isEditingContent(event) && event.key === '/' && !isOpen)
(!isEditingContent(event) && isSlash && !isOpen)
) {
event.preventDefault();
@ -69,5 +80,5 @@ export function useDocSearchKeyboardEvents({
return (): void => {
window.removeEventListener('keydown', onKeyDown);
};
}, [isOpen, onOpen, onClose, onInput, searchButtonRef, isAskAiActive, onAskAiToggle]);
}, [isOpen, onOpen, onClose, onInput, searchButtonRef, isAskAiActive, onAskAiToggle, resolvedShortcuts]);
}

View file

@ -468,6 +468,97 @@ When provided, an informative message wrapped with your link will be displayed o
/>
</div>
## `keyboardShortcuts`
> `type: KeyboardShortcuts` | **optional**
Configuration for keyboard shortcuts that trigger the search modal.
### Default behavior:
- `Ctrl/Cmd+K` - Opens and closes the search modal
- `/` - Opens the search modal (doesn't close)
### Interface:
```typescript
interface KeyboardShortcuts {
'Ctrl/Cmd+K'?: boolean; // default: true
'/'?: boolean; // default: true
}
```
<Tabs
groupId="language"
defaultValue="js"
values={[
{ label: 'JavaScript', value: 'js', },
{ label: 'React', value: 'react', }
]
}>
<TabItem value="js">
```js
// Default - all shortcuts enabled
docsearch({
// ...
});
// Disable slash shortcut
docsearch({
// ...
keyboardShortcuts: { '/': false }
});
// Disable Ctrl/Cmd+K shortcut (also hides button hint)
docsearch({
// ...
keyboardShortcuts: { 'Ctrl/Cmd+K': false }
});
// Disable all keyboard shortcuts
docsearch({
// ...
keyboardShortcuts: { 'Ctrl/Cmd+K': false, '/': false }
});
```
</TabItem>
<TabItem value="react">
```jsx
{/* Default - all shortcuts enabled */}
<DocSearch
// ...
/>
{/* Disable slash shortcut */}
<DocSearch
// ...
keyboardShortcuts={{ '/': false }}
/>
{/* Disable Ctrl/Cmd+K shortcut (also hides button hint) */}
<DocSearch
// ...
keyboardShortcuts={{ 'Ctrl/Cmd+K': false }}
/>
{/* Disable all keyboard shortcuts */}
<DocSearch
// ...
keyboardShortcuts={{ 'Ctrl/Cmd+K': false, '/': false }}
/>
```
</TabItem>
</Tabs>
:::info Keyboard Shortcut Behavior
- **Ctrl/Cmd+K**: Toggle shortcut that both opens and closes the modal
- **/**: Character shortcut that only opens the modal (prevents interference with search typing)
- **Escape**: Always works to close the modal regardless of configuration
:::
## `resultsFooterComponent`
> `type: ({ state }) => JSX.Element` | **optional**