mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
Adding keyboard shortcuts to UI.
This commit is contained in:
parent
da953ae59a
commit
3f411679bd
9 changed files with 942 additions and 66 deletions
235
app/cypress/e2e/keyboard-shortcuts.cy.js
Normal file
235
app/cypress/e2e/keyboard-shortcuts.cy.js
Normal 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
20
app/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)/)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
134
app/src/components/HelpModal.js
Normal file
134
app/src/components/HelpModal.js
Normal 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>
|
||||
);
|
||||
}
|
||||
217
app/src/components/KeyboardShortcutsProvider.js
Normal file
217
app/src/components/KeyboardShortcutsProvider.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}) {
|
||||
|
|
|
|||
19
app/src/contexts/KeyboardShortcutsContext.js
Normal file
19
app/src/contexts/KeyboardShortcutsContext.js
Normal 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: () => {},
|
||||
});
|
||||
293
app/src/hooks/useKeyboardShortcuts.test.js
Normal file
293
app/src/hooks/useKeyboardShortcuts.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue