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:
parent
d82fb7cc65
commit
5be3e846a9
8 changed files with 324 additions and 10 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
24
packages/docsearch-react/src/constants/keyboardShortcuts.ts
Normal file
24
packages/docsearch-react/src/constants/keyboardShortcuts.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
14
packages/docsearch-react/src/types/KeyboardShortcuts.ts
Normal file
14
packages/docsearch-react/src/types/KeyboardShortcuts.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -2,4 +2,5 @@ export * from './DocSearchHit';
|
|||
export * from './DocSearchState';
|
||||
export * from './DocSearchTheme';
|
||||
export * from './InternalDocSearchHit';
|
||||
export * from './KeyboardShortcuts';
|
||||
export * from './StoredDocSearchHit';
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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**
|
||||
|
|
|
|||
Loading…
Reference in a new issue