Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions bundlesize.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@
},
{
"path": "packages/docsearch-react/dist/umd/index.js",
"maxSize": "27 kB"
"maxSize": "27.5 kB"
},
{
"path": "packages/docsearch-js/dist/umd/index.js",
"maxSize": "35.5 kB"
"maxSize": "36 kB"
}
]
}
17 changes: 15 additions & 2 deletions packages/docsearch-react/src/DocSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -41,6 +47,7 @@ export interface DocSearchProps {
translations?: DocSearchTranslations;
getMissingResultsUrl?: ({ query }: { query: string }) => string;
insights?: AutocompleteOptions<InternalDocSearchHit>['insights'];
keyboardShortcuts?: KeyboardShortcuts;
}

export function DocSearch({ ...props }: DocSearchProps): JSX.Element {
Expand Down Expand Up @@ -71,12 +78,18 @@ export function DocSearch({ ...props }: DocSearchProps): JSX.Element {
onClose,
onInput,
searchButtonRef,
keyboardShortcuts: props.keyboardShortcuts,
});
useTheme({ theme: props.theme });

return (
<>
<DocSearchButton ref={searchButtonRef} translations={props?.translations?.button} onClick={onOpen} />
<DocSearchButton
ref={searchButtonRef}
translations={props?.translations?.button}
keyboardShortcuts={props.keyboardShortcuts}
onClick={onOpen}
/>

{isOpen &&
createPortal(
Expand Down
14 changes: 9 additions & 5 deletions packages/docsearch-react/src/DocSearchButton.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import React, { useEffect, useState, type JSX } from 'react';

import { getKeyboardShortcuts } from './constants/keyboardShortcuts';
import { ControlKeyIcon } from './icons/ControlKeyIcon';
import { SearchIcon } from './icons/SearchIcon';
import type { DocSearchTheme } from './types';
import type { DocSearchTheme, KeyboardShortcuts } from './types';
import { useTheme } from './useTheme';

export type ButtonTranslations = Partial<{
Expand All @@ -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;
Expand All @@ -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 });
Expand All @@ -40,14 +43,15 @@ export const DocSearchButton = React.forwardRef<HTMLButtonElement, DocSearchButt
([ACTION_KEY_DEFAULT, 'Control', <ControlKeyIcon />] as const)
: (['Meta', 'Meta', key] 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}
>
Expand All @@ -57,7 +61,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">K</DocSearchButtonKey>
Expand Down
152 changes: 152 additions & 0 deletions packages/docsearch-react/src/__tests__/keyboardShortcuts.test.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 24 additions & 0 deletions packages/docsearch-react/src/constants/keyboardShortcuts.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 14 additions & 0 deletions packages/docsearch-react/src/types/KeyboardShortcuts.ts
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions packages/docsearch-react/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ export * from './DocSearchHit';
export * from './DocSearchState';
export * from './DocSearchTheme';
export * from './InternalDocSearchHit';
export * from './KeyboardShortcuts';
export * from './StoredDocSearchHit';
17 changes: 14 additions & 3 deletions packages/docsearch-react/src/useDocSearchKeyboardEvents.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import React from 'react';

import { getKeyboardShortcuts } from './constants/keyboardShortcuts';
import type { KeyboardShortcuts } from './types';

export interface UseDocSearchKeyboardEventsProps {
isOpen: boolean;
onOpen: () => void;
onClose: () => void;
onInput?: (event: KeyboardEvent) => void;
searchButtonRef: React.RefObject<HTMLButtonElement | null>;
keyboardShortcuts?: KeyboardShortcuts;
}

function isEditingContent(event: KeyboardEvent): boolean {
Expand All @@ -21,19 +25,26 @@ export function useDocSearchKeyboardEvents({
onClose,
onInput,
searchButtonRef,
keyboardShortcuts,
}: UseDocSearchKeyboardEventsProps): void {
const resolvedShortcuts = getKeyboardShortcuts(keyboardShortcuts);

React.useEffect(() => {
function onKeyDown(event: KeyboardEvent): void {
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();

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