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>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
0cff3902b2
commit
f69ce33dd0
@@ -0,0 +1,198 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,24 @@
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Nav } from '../../src/marketing/Nav.tsx';
|
||||
|
||||
function deviceTheme(dark: boolean) {
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches: dark && query === '(prefers-color-scheme: dark)',
|
||||
media: query,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
}
|
||||
|
||||
describe('Nav', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
deviceTheme(false);
|
||||
});
|
||||
|
||||
it('links the brand to the home page', () => {
|
||||
render(<Nav />);
|
||||
expect(screen.getByRole('link', { name: 'Wursor' })).toHaveAttribute('href', '/');
|
||||
@@ -57,4 +72,39 @@ describe('Nav', () => {
|
||||
await userEvent.click(within(menu).getByRole('link', { name: 'Pricing' }));
|
||||
expect(screen.queryByRole('navigation', { name: 'Mobile' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('theme toggle', () => {
|
||||
it('is off on a light device', () => {
|
||||
render(<Nav />);
|
||||
expect(screen.getByRole('button', { name: 'Dark theme' })).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('is on when the device is dark and nothing is saved', () => {
|
||||
deviceTheme(true);
|
||||
render(<Nav />);
|
||||
expect(screen.getByRole('button', { name: 'Dark theme' })).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('is on when dark was chosen earlier, even on a light device', () => {
|
||||
localStorage.setItem('wursor-theme', 'dark');
|
||||
render(<Nav />);
|
||||
expect(screen.getByRole('button', { name: 'Dark theme' })).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('switches the page to dark and remembers it', async () => {
|
||||
render(<Nav />);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Dark theme' }));
|
||||
expect(document.documentElement).toHaveAttribute('data-theme', 'dark');
|
||||
expect(localStorage.getItem('wursor-theme')).toBe('dark');
|
||||
expect(screen.getByRole('button', { name: 'Dark theme' })).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('switches a dark device to light', async () => {
|
||||
deviceTheme(true);
|
||||
render(<Nav />);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Dark theme' }));
|
||||
expect(document.documentElement).toHaveAttribute('data-theme', 'light');
|
||||
expect(screen.getByRole('button', { name: 'Dark theme' })).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SEARCH_ENTRIES, isPaletteShortcut, searchEntries, shortcutLabel, type SearchEntry } from '../../src/marketing/search.ts';
|
||||
|
||||
const entry = (id: string, title: string, description = '', group: SearchEntry['group'] = 'Page'): SearchEntry => ({
|
||||
id, title, description, group, href: `/${id}`,
|
||||
});
|
||||
|
||||
describe('searchEntries', () => {
|
||||
const entries = [
|
||||
entry('a', 'Pricing', 'Plans for every size'),
|
||||
entry('b', 'Review and approve', 'Compare before and after, then publish', 'Docs topic'),
|
||||
entry('c', 'Trust', 'We never publish without approval'),
|
||||
];
|
||||
|
||||
it('shows the pages when nothing has been typed', () => {
|
||||
expect(searchEntries('', entries).map((e) => e.id)).toEqual(['a', 'c']);
|
||||
});
|
||||
|
||||
it('ignores case and surrounding spaces', () => {
|
||||
expect(searchEntries(' PRICING ', entries).map((e) => e.id)).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('finds an entry by its description', () => {
|
||||
expect(searchEntries('compare', entries).map((e) => e.id)).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('needs every word to match', () => {
|
||||
expect(searchEntries('pricing banana', entries)).toEqual([]);
|
||||
});
|
||||
|
||||
it('puts title matches before description-only matches', () => {
|
||||
expect(searchEntries('approv', entries).map((e) => e.id)).toEqual(['b', 'c']);
|
||||
});
|
||||
|
||||
it('puts titles that start with the query first', () => {
|
||||
const list = [entry('x', 'Undo a change'), entry('y', 'How to undo')];
|
||||
expect(searchEntries('undo', list).map((e) => e.id)).toEqual(['x', 'y']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SEARCH_ENTRIES', () => {
|
||||
it('only holds results that go somewhere', () => {
|
||||
expect(SEARCH_ENTRIES.length).toBeGreaterThan(0);
|
||||
for (const e of SEARCH_ENTRIES) expect(e.href).toMatch(/^\//);
|
||||
});
|
||||
|
||||
it('has unique ids', () => {
|
||||
const ids = SEARCH_ENTRIES.map((e) => e.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it('lists every navigation page', () => {
|
||||
const titles = searchEntries('').map((e) => e.title);
|
||||
expect(titles).toEqual(expect.arrayContaining(['Product', 'How it works', 'Trust and safety', 'Pricing', 'Docs']));
|
||||
});
|
||||
|
||||
it('finds a docs topic by what it covers', () => {
|
||||
expect(searchEntries('invite').map((e) => e.title)).toContain('Team and billing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPaletteShortcut', () => {
|
||||
const key = (k: string, mods: { metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean; altKey?: boolean } = {}) =>
|
||||
isPaletteShortcut({ key: k, metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, ...mods });
|
||||
|
||||
it('accepts Cmd+K and Ctrl+K', () => {
|
||||
expect(key('k', { metaKey: true })).toBe(true);
|
||||
expect(key('k', { ctrlKey: true })).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a capital K (Caps Lock)', () => {
|
||||
expect(key('K', { metaKey: true })).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects K alone, other letters, and extra modifiers', () => {
|
||||
expect(key('k')).toBe(false);
|
||||
expect(key('j', { metaKey: true })).toBe(false);
|
||||
expect(key('k', { metaKey: true, shiftKey: true })).toBe(false);
|
||||
expect(key('k', { ctrlKey: true, altKey: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shortcutLabel', () => {
|
||||
it('shows the Command symbol on Apple devices and Ctrl elsewhere', () => {
|
||||
expect(shortcutLabel(true)).toBe('⌘K');
|
||||
expect(shortcutLabel(false)).toBe('Ctrl K');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { THEME_KEY, applyTheme, currentTheme, readStoredTheme, themeInitScript } from '../../src/marketing/theme.ts';
|
||||
|
||||
describe('theme', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
});
|
||||
|
||||
it('has no stored theme until the visitor chooses one', () => {
|
||||
expect(readStoredTheme()).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores a stored value that is not light or dark', () => {
|
||||
localStorage.setItem(THEME_KEY, 'purple');
|
||||
expect(readStoredTheme()).toBeNull();
|
||||
});
|
||||
|
||||
it('applies a theme to the page and remembers it', () => {
|
||||
applyTheme('dark');
|
||||
expect(document.documentElement).toHaveAttribute('data-theme', 'dark');
|
||||
expect(readStoredTheme()).toBe('dark');
|
||||
});
|
||||
|
||||
it('still applies the theme when storage is blocked', () => {
|
||||
const original = Storage.prototype.setItem;
|
||||
Storage.prototype.setItem = () => {
|
||||
throw new Error('blocked');
|
||||
};
|
||||
try {
|
||||
expect(() => applyTheme('light')).not.toThrow();
|
||||
expect(document.documentElement).toHaveAttribute('data-theme', 'light');
|
||||
} finally {
|
||||
Storage.prototype.setItem = original;
|
||||
}
|
||||
});
|
||||
|
||||
it('gives the head script that sets the saved theme before first paint', () => {
|
||||
localStorage.setItem(THEME_KEY, 'dark');
|
||||
new Function(themeInitScript)();
|
||||
expect(document.documentElement).toHaveAttribute('data-theme', 'dark');
|
||||
});
|
||||
|
||||
it('leaves the page on the device theme when nothing is saved', () => {
|
||||
new Function(themeInitScript)();
|
||||
expect(document.documentElement).not.toHaveAttribute('data-theme');
|
||||
});
|
||||
|
||||
it('treats a browser without matchMedia as light instead of crashing', () => {
|
||||
const original = window.matchMedia;
|
||||
// @ts-expect-error simulating an environment that lacks matchMedia
|
||||
window.matchMedia = undefined;
|
||||
try {
|
||||
expect(currentTheme()).toBe('light');
|
||||
} finally {
|
||||
window.matchMedia = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user