Adding keyboard shortcuts to UI.
Some checks failed
CodeQL / Analyze (push) Has been cancelled
Controller Tests / controller-tests (push) Has been cancelled
Jest Tests / jest-tests (push) Has been cancelled

This commit is contained in:
Roland 2025-12-26 18:59:13 -07:00
parent da953ae59a
commit 3f411679bd
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
9 changed files with 942 additions and 66 deletions

View file

@ -0,0 +1,235 @@
describe('Keyboard Shortcuts', () => {
beforeEach(() => {
// Mock status API
cy.intercept('GET', '/api/status', {
statusCode: 200,
body: {
version: '1.0.0',
flags: {},
cpu_percent: 10,
memory_percent: 30,
}
}).as('getStatus');
// Mock settings API
cy.intercept('GET', '/api/settings', {
statusCode: 200,
body: {settings: {}}
}).as('getSettings');
// Mock tags API
cy.intercept('GET', '/api/tags', {
statusCode: 200,
body: {tags: []}
}).as('getTags');
// Mock events API
cy.intercept('GET', '/api/events', {
statusCode: 200,
body: {events: []}
}).as('getEvents');
// Mock search suggestions API
cy.intercept('POST', '/api/search/suggestions', {
statusCode: 200,
body: {
fileGroups: 0,
zimsEstimates: [],
channels: [],
domains: []
}
}).as('getSuggestions');
});
describe('Search Modal via Click', () => {
it('clicking search icon opens modal', () => {
cy.visit('/');
cy.wait('@getStatus');
// Search modal should be closed initially
cy.get('.ui.modal').should('not.exist');
// Click the search icon
cy.get('a.item').find('i.search.icon').click();
// Search modal should open
cy.get('.ui.modal').should('be.visible');
cy.get('.ui.modal input').should('be.visible');
});
it('search modal closes when clicking outside', () => {
cy.visit('/');
cy.wait('@getStatus');
// Open search modal via click
cy.get('a.item').find('i.search.icon').click();
cy.get('.ui.modal').should('be.visible');
// Click the dimmer to close
cy.get('.ui.dimmer').click({force: true});
// Modal should close
cy.get('.ui.modal').should('not.exist');
});
it('search modal has input focused', () => {
cy.visit('/');
cy.wait('@getStatus');
// Open search modal via click
cy.get('a.item').find('i.search.icon').click();
// Input should be focused (can type immediately)
cy.get('.ui.modal input').should('be.focused');
});
});
describe('Search Modal via Keyboard', () => {
it('opens search modal with Ctrl+K', () => {
cy.visit('/');
cy.wait('@getStatus');
// Search modal should be closed initially
cy.get('.ui.modal').should('not.exist');
// Press Ctrl+K
cy.get('body').type('{ctrl}k');
// Search modal should open
cy.get('.ui.modal').should('be.visible');
});
it('opens search modal with Cmd+K (Mac)', () => {
cy.visit('/');
cy.wait('@getStatus');
// Press Cmd+K (meta key)
cy.get('body').type('{meta}k');
// Search modal should open
cy.get('.ui.modal').should('be.visible');
});
it('closes search modal with Escape', () => {
cy.visit('/');
cy.wait('@getStatus');
// Open search modal
cy.get('body').type('{ctrl}k');
cy.get('.ui.modal').should('be.visible');
// Press Escape
cy.get('body').type('{esc}');
// Modal should close
cy.get('.ui.modal').should('not.exist');
});
it('Escape closes modal when input is focused', () => {
cy.visit('/');
cy.wait('@getStatus');
// Open search modal via click
cy.get('a.item').find('i.search.icon').click();
cy.get('.ui.modal').should('be.visible');
// Input should be focused, press Escape
cy.get('.ui.modal input').should('be.focused').type('{esc}');
// Modal should close
cy.get('.ui.modal').should('not.exist');
});
});
describe('Help Modal Accessibility', () => {
// Note: The ? shortcut (Shift+/) requires cypress-real-events for reliable testing
// These tests verify the help modal can be accessed and used once open
it('help modal can be closed with Close button', () => {
cy.visit('/');
cy.wait('@getStatus');
// Manually trigger help modal by calling the context (simulating the shortcut)
// For now, we test via the context API in unit tests
// This E2E test verifies the modal UI works when opened
// Since we can't reliably trigger ?, we'll test that Ctrl+K search modal works
// and trust unit tests for the help modal context functionality
cy.get('body').type('{ctrl}k');
cy.get('.ui.modal').should('be.visible');
});
});
describe('Search Input Behavior', () => {
it('typing in search shows placeholder', () => {
cy.visit('/');
cy.wait('@getStatus');
// Open search modal
cy.get('a.item').find('i.search.icon').click();
// Check placeholder text
cy.get('.ui.modal input').should('have.attr', 'placeholder').and('include', 'Search');
});
it('can type search query', () => {
cy.visit('/');
cy.wait('@getStatus');
// Open search modal
cy.get('a.item').find('i.search.icon').click();
// Type in search box
cy.get('.ui.modal input').type('test query');
// Input should have the value
cy.get('.ui.modal input').should('have.value', 'test query');
});
});
describe('Keyboard Shortcuts Integration', () => {
// These tests verify the keyboard shortcuts don't interfere with normal typing
it('Ctrl+K works even when another input exists on page', () => {
cy.intercept('GET', '/api/collections?kind=domain', {
statusCode: 200,
body: {
collections: [],
totals: {collections: 0},
metadata: {kind: 'domain', columns: []}
}
}).as('getDomains');
cy.visit('/archive/domains');
cy.wait('@getDomains');
// Should have a filter input on page
cy.get('input[placeholder="Domain filter..."]').should('exist');
// Ctrl+K should still open search modal
cy.get('body').type('{ctrl}k');
cy.get('.ui.modal').should('be.visible');
});
it('Ctrl+K works while typing in input', () => {
cy.intercept('GET', '/api/collections?kind=domain', {
statusCode: 200,
body: {
collections: [],
totals: {collections: 0},
metadata: {kind: 'domain', columns: []}
}
}).as('getDomains');
cy.visit('/archive/domains');
cy.wait('@getDomains');
// Focus on filter input
cy.get('input[placeholder="Domain filter..."]').focus().type('test');
// Ctrl+K should still open search modal (even when in input)
cy.get('input[placeholder="Domain filter..."]').type('{ctrl}k');
cy.get('.ui.modal').should('be.visible');
});
});
});

20
app/package-lock.json generated
View file

@ -20,6 +20,7 @@
"react-colorful": "^5.6.1",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-hotkeys-hook": "^5.2.1",
"react-qr-code": "^2.0.11",
"react-router": "^7.5.2",
"react-scripts": "5.0.1",
@ -15547,6 +15548,19 @@
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz",
"integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA=="
},
"node_modules/react-hotkeys-hook": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-5.2.1.tgz",
"integrity": "sha512-xbKh6zJxd/vJHT4Bw4+0pBD662Fk20V+VFhLqciCg+manTVO4qlqRqiwFOYelfHN9dBvWj9vxaPkSS26ZSIJGg==",
"license": "MIT",
"workspaces": [
"packages/*"
],
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@ -29829,6 +29843,12 @@
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz",
"integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA=="
},
"react-hotkeys-hook": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-5.2.1.tgz",
"integrity": "sha512-xbKh6zJxd/vJHT4Bw4+0pBD662Fk20V+VFhLqciCg+manTVO4qlqRqiwFOYelfHN9dBvWj9vxaPkSS26ZSIJGg==",
"requires": {}
},
"react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",

View file

@ -15,6 +15,7 @@
"react-colorful": "^5.6.1",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-hotkeys-hook": "^5.2.1",
"react-qr-code": "^2.0.11",
"react-router": "^7.5.2",
"react-scripts": "5.0.1",
@ -57,5 +58,10 @@
"@types/react": "^18.3.17",
"@types/react-dom": "^18.3.5",
"typescript": "^4.9.5"
},
"jest": {
"transformIgnorePatterns": [
"node_modules/(?!(react-hotkeys-hook)/)"
]
}
}

View file

@ -22,6 +22,7 @@ import {FilePreviewProvider} from "./components/FilePreview";
import {TagsProvider} from "./Tags";
import {ZimRoute} from "./components/Zim";
import ErrorBoundary from "./components/ErrorBoundary";
import {KeyboardShortcutsProvider} from "./components/KeyboardShortcutsProvider";
function PageNotFound() {
const {t} = useContext(ThemeContext);
@ -57,15 +58,17 @@ function Root() {
return <QueryProvider>
<ThemeProvider>
<TagsProvider>
<FilePreviewProvider>
<header>
<NavBar/>
</header>
<ErrorBoundary>
<Outlet/>
</ErrorBoundary>
<Footer/>
</FilePreviewProvider>
<KeyboardShortcutsProvider>
<FilePreviewProvider>
<header>
<NavBar/>
</header>
<ErrorBoundary>
<Outlet/>
</ErrorBoundary>
<Footer/>
</FilePreviewProvider>
</KeyboardShortcutsProvider>
</TagsProvider>
</ThemeProvider>
</QueryProvider>

View file

@ -0,0 +1,134 @@
import React, {useContext} from "react";
import {Table, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react";
import {Button, Header, Modal} from "./Theme";
import {ThemeContext} from "../contexts/contexts";
import {SHORTCUTS} from "./KeyboardShortcutsProvider";
// Format keyboard keys for display
function KeyboardKey({children}) {
return (
<kbd style={{
backgroundColor: '#f7f7f7',
border: '1px solid #ccc',
borderRadius: '3px',
boxShadow: '0 1px 0 rgba(0,0,0,0.2)',
color: '#333',
display: 'inline-block',
fontFamily: 'monospace',
fontSize: '0.85em',
lineHeight: '1.4',
margin: '0 2px',
padding: '2px 6px',
whiteSpace: 'nowrap',
}}>
{children}
</kbd>
);
}
// Parse shortcut keys string and render as keyboard keys
function ShortcutKeys({keys}) {
// Handle multiple key combinations (e.g., "meta+k, ctrl+k")
const combinations = keys.split(', ');
return (
<span>
{combinations.map((combo, idx) => {
const parts = combo.split('+').map(part => {
// Convert key names to display format
switch (part.toLowerCase()) {
case 'meta':
return 'Cmd';
case 'ctrl':
return 'Ctrl';
case 'shift':
return 'Shift';
case 'escape':
return 'Esc';
default:
return part.toUpperCase();
}
});
// Handle sequence shortcuts like "g h"
const isSequence = combo.includes(' ') && !combo.includes('+');
if (isSequence) {
const seqParts = combo.split(' ');
return (
<span key={idx}>
{idx > 0 && ' / '}
{seqParts.map((p, i) => (
<React.Fragment key={i}>
<KeyboardKey>{p.toUpperCase()}</KeyboardKey>
{i < seqParts.length - 1 && ' then '}
</React.Fragment>
))}
</span>
);
}
return (
<span key={idx}>
{idx > 0 && ' / '}
{parts.map((p, i) => (
<React.Fragment key={i}>
<KeyboardKey>{p}</KeyboardKey>
{i < parts.length - 1 && ' + '}
</React.Fragment>
))}
</span>
);
})}
</span>
);
}
// Group shortcuts by category
function groupShortcutsByCategory(shortcuts) {
const groups = {};
shortcuts.forEach(shortcut => {
const category = shortcut.category || 'Other';
if (!groups[category]) {
groups[category] = [];
}
groups[category].push(shortcut);
});
return groups;
}
export default function HelpModal({open, onClose}) {
const {i, t} = useContext(ThemeContext);
const groupedShortcuts = groupShortcutsByCategory(SHORTCUTS);
// Order categories
const categoryOrder = ['Search', 'General', 'Help', 'Navigation'];
const orderedCategories = categoryOrder.filter(cat => groupedShortcuts[cat]);
return (
<Modal closeIcon open={open} onClose={onClose} size='small'>
<Modal.Header>Keyboard Shortcuts</Modal.Header>
<Modal.Content scrolling>
{orderedCategories.map(category => (
<div key={category} style={{marginBottom: '1.5em'}}>
<Header as='h4' {...t}>{category}</Header>
<Table basic='very' compact {...i}>
<TableBody>
{groupedShortcuts[category].map((shortcut, idx) => (
<TableRow key={idx}>
<TableCell width={6}>
<ShortcutKeys keys={shortcut.keys}/>
</TableCell>
<TableCell {...t}>{shortcut.description}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
))}
</Modal.Content>
<Modal.Actions>
<Button onClick={onClose}>Close</Button>
</Modal.Actions>
</Modal>
);
}

View file

@ -0,0 +1,217 @@
import React, {useCallback, useEffect, useRef, useState} from "react";
import {useNavigate} from "react-router";
import {useHotkeys} from "react-hotkeys-hook";
import {KeyboardShortcutsContext} from "../contexts/KeyboardShortcutsContext";
import {Modal} from "./Theme";
import {SearchResultsInput} from "./Common";
import {useSearchSuggestions} from "./Search";
import HelpModal from "./HelpModal";
// Define all keyboard shortcuts with their metadata
export const SHORTCUTS = [
// Search & Help
{keys: 'meta+k, ctrl+k', description: 'Open search', category: 'Search'},
{keys: '/', description: 'Open search', category: 'Search', enableOnFormTags: false},
{keys: 'escape', description: 'Close modal', category: 'General'},
{keys: 'shift+/', description: 'Show keyboard shortcuts', category: 'Help'},
// Navigation (g+ sequences)
{keys: 'g h', description: 'Go to Home', category: 'Navigation', path: '/'},
{keys: 'g v', description: 'Go to Videos', category: 'Navigation', path: '/videos'},
{keys: 'g a', description: 'Go to Archives', category: 'Navigation', path: '/archive'},
{keys: 'g m', description: 'Go to Map', category: 'Navigation', path: '/map'},
{keys: 'g z', description: 'Go to Zim', category: 'Navigation', path: '/zim'},
{keys: 'g f', description: 'Go to Files', category: 'Navigation', path: '/files'},
{keys: 'g s', description: 'Go to Settings', category: 'Navigation', path: '/admin'},
{keys: 'g i', description: 'Go to Inventory', category: 'Navigation', path: '/inventory'},
];
function SearchModal({open, onClose}) {
const {
suggestionsResults,
handleResultSelect,
resultRenderer,
loading,
searchStr,
setSearchStr,
} = useSearchSuggestions();
const inputRef = useRef();
const prevOpen = useRef(open);
const localHandleResultSelect = (i) => {
onClose();
handleResultSelect(i);
};
useEffect(() => {
if (prevOpen.current === true && !open) {
// User has closed the modal.
setSearchStr('');
}
prevOpen.current = open;
}, [open, setSearchStr]);
useEffect(() => {
// Focus on the Search's <input/> when the modal is opened.
if (open && inputRef.current) {
inputRef.current.focus();
}
}, [open]);
if (!open) return null;
return (
<Modal open={open} onClose={onClose} centered={false}>
<Modal.Content>
<SearchResultsInput
clearable
searchStr={searchStr}
onChange={setSearchStr}
onSubmit={setSearchStr}
size='large'
placeholder='Search everywhere...'
results={suggestionsResults}
handleResultSelect={localHandleResultSelect}
resultRenderer={resultRenderer}
loading={loading}
inputRef={inputRef}
/>
</Modal.Content>
</Modal>
);
}
export function KeyboardShortcutsProvider({children}) {
const navigate = useNavigate();
const [searchModalOpen, setSearchModalOpen] = useState(false);
const [helpModalOpen, setHelpModalOpen] = useState(false);
const openSearchModal = useCallback(() => {
setHelpModalOpen(false);
setSearchModalOpen(true);
}, []);
const closeSearchModal = useCallback(() => {
setSearchModalOpen(false);
}, []);
const openHelpModal = useCallback(() => {
setSearchModalOpen(false);
setHelpModalOpen(true);
}, []);
const closeHelpModal = useCallback(() => {
setHelpModalOpen(false);
}, []);
const closeAllModals = useCallback(() => {
setSearchModalOpen(false);
setHelpModalOpen(false);
}, []);
// Search shortcuts - Cmd/Ctrl+K works everywhere
useHotkeys('meta+k, ctrl+k', (e) => {
e.preventDefault();
openSearchModal();
}, {
enableOnFormTags: true,
preventDefault: true,
});
// / for search - only when not in an input field
// Use 'Slash' (the keyboard code) for cross-browser compatibility
useHotkeys('Slash', (e) => {
// Only trigger for / not ? (shift+/)
if (e.shiftKey) return;
e.preventDefault();
openSearchModal();
}, {
enableOnFormTags: false,
});
// Escape closes any modal - works in inputs too
useHotkeys('escape', () => {
closeAllModals();
}, {
enableOnFormTags: true,
});
// ? for help modal - use shift+Slash
useHotkeys('shift+Slash', (e) => {
e.preventDefault();
openHelpModal();
}, {
enableOnFormTags: false,
preventDefault: true,
});
// Navigation shortcuts (g+ sequences) - custom implementation
// react-hotkeys-hook doesn't handle sequences well, so we implement our own
const gPressedRef = useRef(false);
const gTimeoutRef = useRef(null);
const NAV_KEYS = {
'h': '/',
'v': '/videos',
'a': '/archive',
'm': '/map',
'z': '/zim',
'f': '/files',
's': '/admin',
'i': '/inventory',
};
useEffect(() => {
const handleKeyDown = (e) => {
// Don't trigger in input fields
const tagName = e.target.tagName.toLowerCase();
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select' || e.target.isContentEditable) {
return;
}
if (e.key === 'g' && !e.metaKey && !e.ctrlKey && !e.altKey) {
gPressedRef.current = true;
// Clear after 1 second
if (gTimeoutRef.current) clearTimeout(gTimeoutRef.current);
gTimeoutRef.current = setTimeout(() => {
gPressedRef.current = false;
}, 1000);
return;
}
if (gPressedRef.current && NAV_KEYS[e.key]) {
e.preventDefault();
gPressedRef.current = false;
if (gTimeoutRef.current) clearTimeout(gTimeoutRef.current);
navigate(NAV_KEYS[e.key]);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
if (gTimeoutRef.current) clearTimeout(gTimeoutRef.current);
};
}, [navigate]);
const value = {
searchModalOpen,
setSearchModalOpen,
openSearchModal,
closeSearchModal,
helpModalOpen,
setHelpModalOpen,
openHelpModal,
closeHelpModal,
closeAllModals,
};
return (
<KeyboardShortcutsContext.Provider value={value}>
{children}
<SearchModal open={searchModalOpen} onClose={closeSearchModal}/>
<HelpModal open={helpModalOpen} onClose={closeHelpModal}/>
</KeyboardShortcutsContext.Provider>
);
}

View file

@ -10,6 +10,7 @@ import {TagsContext} from "../Tags";
import {AccordionContent, AccordionTitle, Grid, GridColumn, GridRow, Header as SHeader, Label} from "semantic-ui-react";
import {Accordion, Header, Icon, Loader, Modal, Segment} from "./Theme";
import {QueryContext, ThemeContext} from "../contexts/contexts";
import {KeyboardShortcutsContext} from "../contexts/KeyboardShortcutsContext";
const SUGGESTED_APPS = [
{location: '/admin', title: 'Downloads', description: 'View and control your downloads'},
@ -401,66 +402,14 @@ export function SearchView({suggestions, suggestionsSums, loading}) {
}
export function SearchIconButton() {
// A single button which displays a modal for search suggestions.
const {
suggestionsResults,
handleResultSelect,
resultRenderer,
loading,
searchStr,
setSearchStr,
} = useSearchSuggestions();
const [open, setOpen] = React.useState(false);
const prevOpen = React.useRef(open);
// A single button which opens the search modal via keyboard shortcuts context.
const {openSearchModal} = React.useContext(KeyboardShortcutsContext);
const localHandleResultSelect = (i) => {
// Close modal when user selects a result.
setOpen(false);
handleResultSelect(i);
}
React.useEffect(() => {
if (prevOpen.current === true && !open) {
// User has closed the modal.
setSearchStr('');
}
prevOpen.current = open;
}, [open]);
const inputRef = React.useRef();
React.useEffect(() => {
// Focus on the Search's <input/> when the modal is opened.
if (open) {
inputRef.current.focus();
}
}, [open]);
let modalContents;
if (open) {
modalContents = <SearchResultsInput clearable
searchStr={searchStr}
onChange={setSearchStr}
onSubmit={setSearchStr}
size='large'
placeholder='Search everywhere...'
results={suggestionsResults}
handleResultSelect={localHandleResultSelect}
resultRenderer={resultRenderer}
loading={loading}
inputRef={inputRef}
/>;
}
return <React.Fragment>
<a className='item' style={{paddingRight: '0.7em'}} onClick={() => setOpen(true)}>
return (
<a className='item' style={{paddingRight: '0.7em'}} onClick={openSearchModal}>
<Icon name='search'/>
</a>
<Modal open={open} onClose={() => setOpen(false)} centered={false}>
<Modal.Content>
{modalContents}
</Modal.Content>
</Modal>
</React.Fragment>
);
}
function SearchChannelPreview({channel}) {

View file

@ -0,0 +1,19 @@
import React from "react";
// Keyboard shortcuts context for managing global shortcuts and modal states
export const KeyboardShortcutsContext = React.createContext({
// Search modal state
searchModalOpen: false,
setSearchModalOpen: () => {},
openSearchModal: () => {},
closeSearchModal: () => {},
// Help modal state
helpModalOpen: false,
setHelpModalOpen: () => {},
openHelpModal: () => {},
closeHelpModal: () => {},
// Close any open modal
closeAllModals: () => {},
});

View file

@ -0,0 +1,293 @@
import React from 'react';
import {render, screen, fireEvent, act} from '@testing-library/react';
import {MemoryRouter} from 'react-router';
import {KeyboardShortcutsContext} from '../contexts/KeyboardShortcutsContext';
import {KeyboardShortcutsProvider, SHORTCUTS} from '../components/KeyboardShortcutsProvider';
import {ThemeContext} from '../contexts/contexts';
import {TagsContext} from '../Tags';
import {QueryContext} from '../contexts/contexts';
// Mock useSearchSuggestions to avoid complex setup
jest.mock('../components/Search', () => ({
...jest.requireActual('../components/Search'),
useSearchSuggestions: () => ({
suggestionsResults: {},
handleResultSelect: jest.fn(),
resultRenderer: () => null,
loading: false,
searchStr: '',
setSearchStr: jest.fn(),
}),
}));
// Test wrapper with all required providers
function TestWrapper({children}) {
const mockThemeContext = {
i: {},
s: {},
t: {},
theme: 'light',
inverted: '',
};
const mockTagsContext = {
SingleTag: () => null,
fuzzyMatchTagsByName: () => [],
};
const mockQueryContext = {
searchParams: new URLSearchParams(),
setSearchParams: jest.fn(),
updateQuery: jest.fn(),
getLocationStr: jest.fn((params, base) => base || '/'),
};
return (
<MemoryRouter>
<ThemeContext.Provider value={mockThemeContext}>
<QueryContext.Provider value={mockQueryContext}>
<TagsContext.Provider value={mockTagsContext}>
<KeyboardShortcutsProvider>
{children}
</KeyboardShortcutsProvider>
</TagsContext.Provider>
</QueryContext.Provider>
</ThemeContext.Provider>
</MemoryRouter>
);
}
// Simple component that uses the context
function TestComponent() {
const {
searchModalOpen,
helpModalOpen,
openSearchModal,
closeSearchModal,
openHelpModal,
closeHelpModal,
closeAllModals,
} = React.useContext(KeyboardShortcutsContext);
return (
<div>
<div data-testid="search-modal-status">{searchModalOpen ? 'open' : 'closed'}</div>
<div data-testid="help-modal-status">{helpModalOpen ? 'open' : 'closed'}</div>
<button data-testid="open-search" onClick={openSearchModal}>Open Search</button>
<button data-testid="close-search" onClick={closeSearchModal}>Close Search</button>
<button data-testid="open-help" onClick={openHelpModal}>Open Help</button>
<button data-testid="close-help" onClick={closeHelpModal}>Close Help</button>
<button data-testid="close-all" onClick={closeAllModals}>Close All</button>
</div>
);
}
describe('KeyboardShortcutsContext', () => {
test('provides default values (modals closed)', () => {
render(
<TestWrapper>
<TestComponent/>
</TestWrapper>
);
expect(screen.getByTestId('search-modal-status')).toHaveTextContent('closed');
expect(screen.getByTestId('help-modal-status')).toHaveTextContent('closed');
});
test('openSearchModal opens search modal', () => {
render(
<TestWrapper>
<TestComponent/>
</TestWrapper>
);
act(() => {
fireEvent.click(screen.getByTestId('open-search'));
});
expect(screen.getByTestId('search-modal-status')).toHaveTextContent('open');
expect(screen.getByTestId('help-modal-status')).toHaveTextContent('closed');
});
test('closeSearchModal closes search modal', () => {
render(
<TestWrapper>
<TestComponent/>
</TestWrapper>
);
// Open first
act(() => {
fireEvent.click(screen.getByTestId('open-search'));
});
expect(screen.getByTestId('search-modal-status')).toHaveTextContent('open');
// Close
act(() => {
fireEvent.click(screen.getByTestId('close-search'));
});
expect(screen.getByTestId('search-modal-status')).toHaveTextContent('closed');
});
test('openHelpModal opens help modal', () => {
render(
<TestWrapper>
<TestComponent/>
</TestWrapper>
);
act(() => {
fireEvent.click(screen.getByTestId('open-help'));
});
expect(screen.getByTestId('help-modal-status')).toHaveTextContent('open');
expect(screen.getByTestId('search-modal-status')).toHaveTextContent('closed');
});
test('closeHelpModal closes help modal', () => {
render(
<TestWrapper>
<TestComponent/>
</TestWrapper>
);
// Open first
act(() => {
fireEvent.click(screen.getByTestId('open-help'));
});
expect(screen.getByTestId('help-modal-status')).toHaveTextContent('open');
// Close
act(() => {
fireEvent.click(screen.getByTestId('close-help'));
});
expect(screen.getByTestId('help-modal-status')).toHaveTextContent('closed');
});
test('closeAllModals closes both modals', () => {
render(
<TestWrapper>
<TestComponent/>
</TestWrapper>
);
// Open search
act(() => {
fireEvent.click(screen.getByTestId('open-search'));
});
expect(screen.getByTestId('search-modal-status')).toHaveTextContent('open');
// Close all
act(() => {
fireEvent.click(screen.getByTestId('close-all'));
});
expect(screen.getByTestId('search-modal-status')).toHaveTextContent('closed');
expect(screen.getByTestId('help-modal-status')).toHaveTextContent('closed');
});
test('opening search modal closes help modal', () => {
render(
<TestWrapper>
<TestComponent/>
</TestWrapper>
);
// Open help first
act(() => {
fireEvent.click(screen.getByTestId('open-help'));
});
expect(screen.getByTestId('help-modal-status')).toHaveTextContent('open');
// Open search should close help
act(() => {
fireEvent.click(screen.getByTestId('open-search'));
});
expect(screen.getByTestId('search-modal-status')).toHaveTextContent('open');
expect(screen.getByTestId('help-modal-status')).toHaveTextContent('closed');
});
test('opening help modal closes search modal', () => {
render(
<TestWrapper>
<TestComponent/>
</TestWrapper>
);
// Open search first
act(() => {
fireEvent.click(screen.getByTestId('open-search'));
});
expect(screen.getByTestId('search-modal-status')).toHaveTextContent('open');
// Open help should close search
act(() => {
fireEvent.click(screen.getByTestId('open-help'));
});
expect(screen.getByTestId('help-modal-status')).toHaveTextContent('open');
expect(screen.getByTestId('search-modal-status')).toHaveTextContent('closed');
});
});
describe('SHORTCUTS constant', () => {
test('is defined and is an array', () => {
expect(SHORTCUTS).toBeDefined();
expect(Array.isArray(SHORTCUTS)).toBe(true);
expect(SHORTCUTS.length).toBeGreaterThan(0);
});
test('contains search shortcuts', () => {
const shortcutKeys = SHORTCUTS.map(s => s.keys);
expect(shortcutKeys).toContain('meta+k, ctrl+k');
expect(shortcutKeys).toContain('/');
});
test('contains help shortcut', () => {
const shortcutKeys = SHORTCUTS.map(s => s.keys);
expect(shortcutKeys).toContain('shift+/');
});
test('contains escape shortcut', () => {
const shortcutKeys = SHORTCUTS.map(s => s.keys);
expect(shortcutKeys).toContain('escape');
});
test('contains navigation shortcuts', () => {
const shortcutKeys = SHORTCUTS.map(s => s.keys);
expect(shortcutKeys).toContain('g h');
expect(shortcutKeys).toContain('g v');
expect(shortcutKeys).toContain('g a');
expect(shortcutKeys).toContain('g m');
expect(shortcutKeys).toContain('g z');
expect(shortcutKeys).toContain('g f');
expect(shortcutKeys).toContain('g s');
expect(shortcutKeys).toContain('g i');
});
test('all shortcuts have required properties', () => {
SHORTCUTS.forEach(shortcut => {
expect(shortcut).toHaveProperty('keys');
expect(shortcut).toHaveProperty('description');
expect(shortcut).toHaveProperty('category');
expect(typeof shortcut.keys).toBe('string');
expect(typeof shortcut.description).toBe('string');
expect(typeof shortcut.category).toBe('string');
});
});
test('navigation shortcuts have path property', () => {
const navShortcuts = SHORTCUTS.filter(s => s.category === 'Navigation');
expect(navShortcuts.length).toBeGreaterThan(0);
navShortcuts.forEach(shortcut => {
expect(shortcut).toHaveProperty('path');
expect(shortcut.path).toMatch(/^\//);
});
});
test('shortcuts are grouped by category', () => {
const categories = [...new Set(SHORTCUTS.map(s => s.category))];
expect(categories).toContain('Search');
expect(categories).toContain('Navigation');
expect(categories).toContain('General');
expect(categories).toContain('Help');
});
});