Files
wursor/web/__tests__/marketing/CommandPalette.test.tsx
T
SinachPatandClaude Sonnet 5.5 f69ce33dd0 feat(web): marketing theme toggle, site search palette, and phone dev access
- Theme toggle in the nav: follows the device until chosen, remembers the choice, sets data-theme before first paint (no flash).
- Site search: Cmd/Ctrl+K command palette on every marketing page, opened by the shortcut, a nav Search button, or the Docs search field. Searches pages, docs topics and articles that have a page.
- Docs topics move to content.ts so the page and the search index share one source.
- next.config: allowedDevOrigins for private network ranges so a phone on the same Wi-Fi hydrates the dev site. Dev only.
- Sun and moon icons added by hand (not Figma exports); noted in the icons README.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 22:27:34 +01:00

199 lines
7.3 KiB
TypeScript

import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CommandPaletteProvider } from '../../src/marketing/CommandPalette.tsx';
import { DocsSearch } from '../../src/marketing/pages/DocsSearch.tsx';
import { Nav } from '../../src/marketing/Nav.tsx';
function setup() {
return render(
<CommandPaletteProvider>
<Nav />
<DocsSearch />
<button type="button">Elsewhere</button>
</CommandPaletteProvider>,
);
}
const dialog = () => screen.queryByRole('dialog', { name: 'Search' });
describe('CommandPalette', () => {
beforeEach(() => {
window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }) as unknown as typeof window.matchMedia;
});
afterEach(() => {
document.body.style.overflow = '';
});
it('is closed until asked for', () => {
setup();
expect(dialog()).not.toBeInTheDocument();
});
it.each([
['Ctrl+K', '{Control>}k{/Control}'],
['Cmd+K', '{Meta>}k{/Meta}'],
])('opens with %s and focuses the search box', async (_name, keys) => {
setup();
await userEvent.keyboard(keys);
expect(dialog()).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: 'Search the site' })).toHaveFocus();
});
it('opens while typing in a text field, and stops the browser acting on the shortcut', async () => {
render(
<CommandPaletteProvider>
<input aria-label="Name" />
</CommandPaletteProvider>,
);
screen.getByRole('textbox', { name: 'Name' }).focus();
const handled: boolean[] = [];
const spy = (e: KeyboardEvent) => e.key === 'k' && handled.push(e.defaultPrevented);
document.addEventListener('keydown', spy);
try {
await userEvent.keyboard('{Control>}k{/Control}');
} finally {
document.removeEventListener('keydown', spy);
}
expect(dialog()).toBeInTheDocument();
expect(handled).toEqual([true]);
});
it('toggles closed with the same shortcut', async () => {
setup();
await userEvent.keyboard('{Control>}k{/Control}');
await userEvent.keyboard('{Control>}k{/Control}');
expect(dialog()).not.toBeInTheDocument();
});
it('closes with Escape and hands focus back to where it was', async () => {
setup();
screen.getByRole('button', { name: 'Elsewhere' }).focus();
await userEvent.keyboard('{Control>}k{/Control}');
await userEvent.keyboard('{Escape}');
expect(dialog()).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Elsewhere' })).toHaveFocus();
});
it('closes when the backdrop is clicked, but not when the panel is', async () => {
setup();
await userEvent.keyboard('{Control>}k{/Control}');
await userEvent.click(screen.getByRole('combobox'));
expect(dialog()).toBeInTheDocument();
await userEvent.click(screen.getByTestId('palette-backdrop'));
expect(dialog()).not.toBeInTheDocument();
});
it('suggests the pages before anything is typed', async () => {
setup();
await userEvent.keyboard('{Control>}k{/Control}');
const options = within(screen.getByRole('listbox')).getAllByRole('option');
expect(options.map((o) => o.textContent)).toEqual(expect.arrayContaining([expect.stringContaining('Pricing'), expect.stringContaining('Docs')]));
});
it('filters as you type and links each result to its page', async () => {
setup();
await userEvent.keyboard('{Control>}k{/Control}');
await userEvent.keyboard('pricing');
const options = within(screen.getByRole('listbox')).getAllByRole('option');
expect(options[0]).toHaveTextContent('Pricing');
expect(options[0]).toHaveAttribute('href', '/pricing');
});
it('says so when nothing matches', async () => {
setup();
await userEvent.keyboard('{Control>}k{/Control}');
await userEvent.keyboard('zzzz');
expect(screen.queryByRole('option')).not.toBeInTheDocument();
expect(screen.getByText(/No results for/)).toHaveTextContent('zzzz');
});
it('moves the selection with the arrow keys and wraps around', async () => {
setup();
await userEvent.keyboard('{Control>}k{/Control}');
const input = screen.getByRole('combobox');
const options = () => within(screen.getByRole('listbox')).getAllByRole('option');
expect(options()[0]).toHaveAttribute('aria-selected', 'true');
expect(input).toHaveAttribute('aria-activedescendant', options()[0]!.id);
await userEvent.keyboard('{ArrowDown}');
expect(options()[1]).toHaveAttribute('aria-selected', 'true');
await userEvent.keyboard('{ArrowUp}{ArrowUp}');
expect(options().at(-1)).toHaveAttribute('aria-selected', 'true');
});
it('opens the selected result with Enter and closes', async () => {
setup();
const clicked = vi.fn();
const onClick = (e: MouseEvent) => {
e.preventDefault();
clicked((e.target as HTMLElement).closest('a')?.getAttribute('href'));
};
document.addEventListener('click', onClick);
try {
await userEvent.keyboard('{Control>}k{/Control}');
await userEvent.keyboard('pricing{Enter}');
expect(clicked).toHaveBeenCalledWith('/pricing');
expect(dialog()).not.toBeInTheDocument();
} finally {
document.removeEventListener('click', onClick);
}
});
it('starts from the top of the list each time it opens', async () => {
setup();
await userEvent.keyboard('{Control>}k{/Control}');
await userEvent.keyboard('zzzz{Escape}');
await userEvent.keyboard('{Control>}k{/Control}');
expect(screen.getByRole('combobox')).toHaveValue('');
expect(screen.getAllByRole('option')[0]).toHaveAttribute('aria-selected', 'true');
});
it('keeps the page from scrolling behind it while open', async () => {
setup();
await userEvent.keyboard('{Control>}k{/Control}');
expect(document.body.style.overflow).toBe('hidden');
await userEvent.keyboard('{Escape}');
expect(document.body.style.overflow).toBe('');
});
it('keeps focus in the search box when Tab is pressed', async () => {
setup();
await userEvent.keyboard('{Control>}k{/Control}');
await userEvent.tab();
expect(screen.getByRole('combobox')).toHaveFocus();
});
});
describe('search triggers', () => {
beforeEach(() => {
window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }) as unknown as typeof window.matchMedia;
});
it('opens from the Search button in the navigation', async () => {
setup();
await userEvent.click(screen.getByRole('button', { name: 'Search' }));
expect(dialog()).toBeInTheDocument();
});
it('opens from the docs search field, which is no longer disabled', async () => {
setup();
const field = screen.getByRole('searchbox', { name: 'Search the docs' });
expect(field).toBeEnabled();
await userEvent.click(field);
expect(dialog()).toBeInTheDocument();
});
it('shows the shortcut on the docs search field', () => {
setup();
expect(screen.getByText('Ctrl K')).toBeInTheDocument();
});
it('does nothing, and does not crash, when there is no provider', async () => {
render(<Nav />);
await userEvent.click(screen.getByRole('button', { name: 'Search' }));
expect(dialog()).not.toBeInTheDocument();
});
});