feat: integrate global keyboard shortcuts across multiple components

- Added `KeyboardShortcutsProvider` to `RootLayout` for centralized keyboard shortcut management.
- Implemented `useGlobalKeyboardShortcuts` in `DailyPageClient`, `KanbanPageClient`, and `HomePageClient` to enhance navigation and task management with keyboard shortcuts.
- Updated `KeyboardShortcuts` component to render a modal for displaying available shortcuts, improving user accessibility.
- Enhanced `Header` component with buttons to open the keyboard shortcuts modal, streamlining user interaction.
This commit is contained in:
Julien Froidefond
2025-09-29 17:29:11 +02:00
parent c1a14f9196
commit 749f69680b
10 changed files with 487 additions and 8 deletions

View File

@@ -14,6 +14,7 @@ import { PendingTasksSection } from '@/components/daily/PendingTasksSection';
import { dailyClient } from '@/clients/daily-client'; import { dailyClient } from '@/clients/daily-client';
import { Header } from '@/components/ui/Header'; import { Header } from '@/components/ui/Header';
import { getPreviousWorkday, formatDateLong, isToday, generateDateTitle, formatDateShort, isYesterday } from '@/lib/date-utils'; import { getPreviousWorkday, formatDateLong, isToday, generateDateTitle, formatDateShort, isYesterday } from '@/lib/date-utils';
import { useGlobalKeyboardShortcuts } from '@/hooks/useGlobalKeyboardShortcuts';
interface DailyPageClientProps { interface DailyPageClientProps {
initialDailyView?: DailyView; initialDailyView?: DailyView;
@@ -85,6 +86,13 @@ export function DailyPageClient({
await refreshDailyDates(); await refreshDailyDates();
}; };
// Raccourcis clavier globaux pour la page Daily
useGlobalKeyboardShortcuts({
onNavigatePrevious: goToPreviousDay,
onNavigateNext: goToNextDay,
onGoToToday: goToToday
});
const handleToggleCheckbox = async (checkboxId: string) => { const handleToggleCheckbox = async (checkboxId: string) => {
await toggleCheckbox(checkboxId); await toggleCheckbox(checkboxId);
}; };

View File

@@ -12,6 +12,7 @@ import { CreateTaskForm } from '@/components/forms/CreateTaskForm';
import { MobileControls } from '@/components/kanban/MobileControls'; import { MobileControls } from '@/components/kanban/MobileControls';
import { DesktopControls } from '@/components/kanban/DesktopControls'; import { DesktopControls } from '@/components/kanban/DesktopControls';
import { useIsMobile } from '@/hooks/useIsMobile'; import { useIsMobile } from '@/hooks/useIsMobile';
import { useGlobalKeyboardShortcuts } from '@/hooks/useGlobalKeyboardShortcuts';
interface KanbanPageClientProps { interface KanbanPageClientProps {
initialTasks: Task[]; initialTasks: Task[];
@@ -55,6 +56,19 @@ function KanbanPageContent() {
setIsCreateModalOpen(false); setIsCreateModalOpen(false);
}; };
// Raccourcis clavier globaux pour la page Kanban
useGlobalKeyboardShortcuts({
onCreateTask: () => setIsCreateModalOpen(true),
onToggleFilters: handleToggleFilters,
onToggleCompactView: handleToggleCompactView,
onToggleSwimlanes: handleToggleSwimlanes,
onOpenSearch: () => {
// Focus sur le champ de recherche dans les contrôles desktop
const searchInput = document.querySelector('input[placeholder*="Rechercher"]') as HTMLInputElement;
searchInput?.focus();
}
});
return ( return (
<div className="min-h-screen bg-[var(--background)]"> <div className="min-h-screen bg-[var(--background)]">
<Header <Header

View File

@@ -4,6 +4,7 @@ import "./globals.css";
import { ThemeProvider } from "@/contexts/ThemeContext"; import { ThemeProvider } from "@/contexts/ThemeContext";
import { JiraConfigProvider } from "@/contexts/JiraConfigContext"; import { JiraConfigProvider } from "@/contexts/JiraConfigContext";
import { UserPreferencesProvider } from "@/contexts/UserPreferencesContext"; import { UserPreferencesProvider } from "@/contexts/UserPreferencesContext";
import { KeyboardShortcutsProvider } from "@/contexts/KeyboardShortcutsContext";
import { userPreferencesService } from "@/services/core/user-preferences"; import { userPreferencesService } from "@/services/core/user-preferences";
import { KeyboardShortcuts } from "@/components/KeyboardShortcuts"; import { KeyboardShortcuts } from "@/components/KeyboardShortcuts";
@@ -39,12 +40,14 @@ export default async function RootLayout({
initialTheme={initialPreferences.viewPreferences.theme} initialTheme={initialPreferences.viewPreferences.theme}
userPreferredTheme={initialPreferences.viewPreferences.theme === 'light' ? 'dark' : initialPreferences.viewPreferences.theme} userPreferredTheme={initialPreferences.viewPreferences.theme === 'light' ? 'dark' : initialPreferences.viewPreferences.theme}
> >
<KeyboardShortcutsProvider>
<KeyboardShortcuts /> <KeyboardShortcuts />
<JiraConfigProvider config={initialPreferences.jiraConfig}> <JiraConfigProvider config={initialPreferences.jiraConfig}>
<UserPreferencesProvider initialPreferences={initialPreferences}> <UserPreferencesProvider initialPreferences={initialPreferences}>
{children} {children}
</UserPreferencesProvider> </UserPreferencesProvider>
</JiraConfigProvider> </JiraConfigProvider>
</KeyboardShortcutsProvider>
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>

View File

@@ -10,6 +10,7 @@ import { RecentTasks } from '@/components/dashboard/RecentTasks';
import { ProductivityAnalytics } from '@/components/dashboard/ProductivityAnalytics'; import { ProductivityAnalytics } from '@/components/dashboard/ProductivityAnalytics';
import { ProductivityMetrics } from '@/services/analytics/analytics'; import { ProductivityMetrics } from '@/services/analytics/analytics';
import { DeadlineMetrics } from '@/services/analytics/deadline-analytics'; import { DeadlineMetrics } from '@/services/analytics/deadline-analytics';
import { useGlobalKeyboardShortcuts } from '@/hooks/useGlobalKeyboardShortcuts';
interface HomePageClientProps { interface HomePageClientProps {
initialTasks: Task[]; initialTasks: Task[];
@@ -31,6 +32,20 @@ function HomePageContent({ productivityMetrics, deadlineMetrics }: {
await createTask(data); await createTask(data);
}; };
// Raccourcis clavier globaux pour la page Dashboard
useGlobalKeyboardShortcuts({
onOpenSearch: () => {
// Focus sur le champ de recherche s'il existe, sinon naviguer vers Kanban
const searchInput = document.querySelector('input[placeholder*="Rechercher"]') as HTMLInputElement;
if (searchInput) {
searchInput.focus();
} else {
// Naviguer vers Kanban où il y a une recherche
window.location.href = '/kanban';
}
}
});
return ( return (
<div className="min-h-screen bg-[var(--background)]"> <div className="min-h-screen bg-[var(--background)]">
<Header <Header

View File

@@ -1,8 +1,19 @@
'use client'; 'use client';
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'; import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
import { useKeyboardShortcutsModal } from '@/contexts/KeyboardShortcutsContext';
import { KeyboardShortcutsModal } from '@/components/ui/KeyboardShortcutsModal';
export function KeyboardShortcuts() { export function KeyboardShortcuts() {
useKeyboardShortcuts(); useKeyboardShortcuts();
return null; // Ce composant ne rend rien, il gère juste les raccourcis const { isOpen, shortcuts, closeModal } = useKeyboardShortcutsModal();
return (
<KeyboardShortcutsModal
isOpen={isOpen}
onClose={closeModal}
shortcuts={shortcuts}
title="Raccourcis clavier"
/>
);
} }

View File

@@ -7,6 +7,7 @@ import Link from 'next/link';
import { useState } from 'react'; import { useState } from 'react';
import { Theme } from '@/lib/theme-config'; import { Theme } from '@/lib/theme-config';
import { THEME_CONFIG, getThemeMetadata } from '@/lib/theme-config'; import { THEME_CONFIG, getThemeMetadata } from '@/lib/theme-config';
import { useKeyboardShortcutsModal } from '@/contexts/KeyboardShortcutsContext';
interface HeaderProps { interface HeaderProps {
title?: string; title?: string;
@@ -20,6 +21,7 @@ export function Header({ title = "TowerControl", subtitle = "Task Management", s
const pathname = usePathname(); const pathname = usePathname();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [themeDropdownOpen, setThemeDropdownOpen] = useState(false); const [themeDropdownOpen, setThemeDropdownOpen] = useState(false);
const { openModal: openShortcutsModal } = useKeyboardShortcutsModal();
// Liste des thèmes disponibles avec leurs labels et icônes // Liste des thèmes disponibles avec leurs labels et icônes
const themes: { value: Theme; label: string; icon: string }[] = THEME_CONFIG.allThemes.map(themeValue => { const themes: { value: Theme; label: string; icon: string }[] = THEME_CONFIG.allThemes.map(themeValue => {
@@ -97,6 +99,15 @@ export function Header({ title = "TowerControl", subtitle = "Task Management", s
{/* Controls mobile/tablette */} {/* Controls mobile/tablette */}
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 flex-shrink-0">
{/* Keyboard Shortcuts */}
<button
onClick={openShortcutsModal}
className="text-[var(--muted-foreground)] hover:text-[var(--primary)] transition-colors p-2 rounded-md hover:bg-[var(--card-hover)]"
title="Raccourcis clavier (Cmd+?)"
>
</button>
{/* Theme Dropdown */} {/* Theme Dropdown */}
<div className="relative"> <div className="relative">
<button <button
@@ -197,6 +208,15 @@ export function Header({ title = "TowerControl", subtitle = "Task Management", s
</Link> </Link>
))} ))}
{/* Keyboard Shortcuts desktop */}
<button
onClick={openShortcutsModal}
className="text-[var(--muted-foreground)] hover:text-[var(--primary)] transition-colors p-1 rounded-md hover:bg-[var(--card-hover)]"
title="Raccourcis clavier (Cmd+?)"
>
</button>
{/* Theme Dropdown desktop */} {/* Theme Dropdown desktop */}
<div className="relative"> <div className="relative">
<button <button

View File

@@ -0,0 +1,96 @@
'use client';
import { Modal } from './Modal';
import { cn } from '@/lib/utils';
interface KeyboardShortcut {
keys: string[];
description: string;
category?: string;
}
interface KeyboardShortcutsModalProps {
isOpen: boolean;
onClose: () => void;
shortcuts: KeyboardShortcut[];
title?: string;
}
function KeyBadge({ keyText }: { keyText: string }) {
return (
<kbd className="inline-flex items-center px-2 py-1 text-xs font-mono font-medium text-[var(--foreground)] bg-[var(--card)] border border-[var(--border)] rounded-md shadow-sm">
{keyText}
</kbd>
);
}
function ShortcutRow({ shortcut }: { shortcut: KeyboardShortcut }) {
return (
<div className="flex items-center justify-between py-2 px-3 rounded-md hover:bg-[var(--card-hover)] transition-colors">
<div className="flex items-center gap-2">
{shortcut.keys.map((key, index) => (
<div key={index} className="flex items-center gap-1">
<KeyBadge keyText={key} />
{index < shortcut.keys.length - 1 && (
<span className="text-[var(--muted-foreground)] text-xs">+</span>
)}
</div>
))}
</div>
<span className="text-sm text-[var(--foreground)] font-mono">
{shortcut.description}
</span>
</div>
);
}
export function KeyboardShortcutsModal({
isOpen,
onClose,
shortcuts,
title = "Raccourcis clavier"
}: KeyboardShortcutsModalProps) {
// Grouper les raccourcis par catégorie
const groupedShortcuts = shortcuts.reduce((acc, shortcut) => {
const category = shortcut.category || 'Général';
if (!acc[category]) {
acc[category] = [];
}
acc[category].push(shortcut);
return acc;
}, {} as Record<string, KeyboardShortcut[]>);
return (
<Modal isOpen={isOpen} onClose={onClose} title={title} size="lg">
<div className="space-y-6">
{Object.entries(groupedShortcuts).map(([category, categoryShortcuts]) => (
<div key={category}>
<h3 className="text-sm font-mono font-semibold text-[var(--primary)] uppercase tracking-wider mb-3">
{category}
</h3>
<div className="space-y-1">
{categoryShortcuts.map((shortcut, index) => (
<ShortcutRow key={index} shortcut={shortcut} />
))}
</div>
</div>
))}
{shortcuts.length === 0 && (
<div className="text-center py-8">
<p className="text-[var(--muted-foreground)] font-mono">
Aucun raccourci disponible sur cette page
</p>
</div>
)}
</div>
{/* Footer avec info */}
<div className="mt-6 pt-4 border-t border-[var(--border)]/50">
<p className="text-xs text-[var(--muted-foreground)] font-mono text-center">
Appuyez sur <KeyBadge keyText="Esc" /> pour fermer cette fenêtre
</p>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,210 @@
'use client';
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { usePathname } from 'next/navigation';
export interface KeyboardShortcut {
keys: string[];
description: string;
category?: string;
}
export interface PageShortcuts {
[pagePath: string]: KeyboardShortcut[];
}
// Configuration des raccourcis par page
const PAGE_SHORTCUTS: PageShortcuts = {
// Raccourcis globaux (toutes les pages)
'*': [
{
keys: ['Cmd', '?'],
description: 'Afficher les raccourcis clavier',
category: 'Navigation'
},
{
keys: ['Cmd', 'Ctrl', 'D'],
description: 'Basculer le thème',
category: 'Apparence'
},
{
keys: ['Cmd', 'Ctrl', 'T'],
description: 'Changer de thème sombre',
category: 'Apparence'
},
{
keys: ['Esc'],
description: 'Fermer les modales/annuler',
category: 'Navigation'
}
],
// Dashboard
'/': [
{
keys: ['Cmd', 'Ctrl', 'K'],
description: 'Vers Kanban',
category: 'Navigation'
}
],
// Kanban
'/kanban': [
{
keys: ['Cmd', 'Ctrl', 'N'],
description: 'Créer une nouvelle tâche',
category: 'Actions'
},
{
keys: ['Space'],
description: 'Basculer la vue compacte',
category: 'Vue'
},
{
keys: ['Cmd', 'Ctrl', 'F'],
description: 'Ouvrir les filtres',
category: 'Filtres'
},
{
keys: ['Cmd', 'Ctrl', 'S'],
description: 'Basculer les swimlanes',
category: 'Vue'
},
{
keys: ['Tab'],
description: 'Navigation entre colonnes',
category: 'Navigation'
},
{
keys: ['Enter'],
description: 'Valider une tâche',
category: 'Actions'
},
{
keys: ['Esc'],
description: 'Annuler la création de tâche',
category: 'Actions'
}
],
// Daily
'/daily': [
{
keys: ['←', '→'],
description: 'Navigation jour précédent/suivant',
category: 'Navigation'
},
{
keys: ['Cmd', 'Ctrl', 'T'],
description: 'Aller à aujourd\'hui',
category: 'Navigation'
},
{
keys: ['Enter'],
description: 'Valider un élément',
category: 'Actions'
}
],
// Manager
'/weekly-manager': [
{
keys: ['Cmd', 'Ctrl', 'N'],
description: 'Créer un objectif',
category: 'Actions'
},
{
keys: ['←', '→'],
description: 'Navigation semaine précédente/suivante',
category: 'Navigation'
},
{
keys: ['Cmd', 'Ctrl', 'T'],
description: 'Aller à cette semaine',
category: 'Navigation'
}
]
};
interface KeyboardShortcutsContextType {
isOpen: boolean;
shortcuts: KeyboardShortcut[];
openModal: () => void;
closeModal: () => void;
toggleModal: () => void;
}
const KeyboardShortcutsContext = createContext<KeyboardShortcutsContextType | undefined>(undefined);
interface KeyboardShortcutsProviderProps {
children: ReactNode;
}
export function KeyboardShortcutsProvider({ children }: KeyboardShortcutsProviderProps) {
const [isOpen, setIsOpen] = useState(false);
const pathname = usePathname();
// Obtenir les raccourcis pour la page actuelle
const getCurrentPageShortcuts = (): KeyboardShortcut[] => {
const shortcuts: KeyboardShortcut[] = [];
// Ajouter les raccourcis globaux
if (PAGE_SHORTCUTS['*']) {
shortcuts.push(...PAGE_SHORTCUTS['*']);
}
// Ajouter les raccourcis spécifiques à la page
const pageShortcuts = PAGE_SHORTCUTS[pathname];
if (pageShortcuts) {
shortcuts.push(...pageShortcuts);
}
return shortcuts;
};
const shortcuts = getCurrentPageShortcuts();
// Gérer le raccourci Cmd+? pour ouvrir la popup
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Cmd+? ou Ctrl+? pour ouvrir les raccourcis
if ((event.metaKey || event.ctrlKey) && event.key === '?') {
event.preventDefault();
setIsOpen(true);
}
// Esc pour fermer
if (event.key === 'Escape' && isOpen) {
setIsOpen(false);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen]);
const contextValue: KeyboardShortcutsContextType = {
isOpen,
shortcuts,
openModal: () => setIsOpen(true),
closeModal: () => setIsOpen(false),
toggleModal: () => setIsOpen(prev => !prev)
};
return (
<KeyboardShortcutsContext.Provider value={contextValue}>
{children}
</KeyboardShortcutsContext.Provider>
);
}
export function useKeyboardShortcutsModal() {
const context = useContext(KeyboardShortcutsContext);
if (context === undefined) {
throw new Error('useKeyboardShortcutsModal must be used within a KeyboardShortcutsProvider');
}
return context;
}

View File

@@ -0,0 +1,102 @@
'use client';
import { useEffect } from 'react';
import { usePathname } from 'next/navigation';
interface KeyboardShortcutsActions {
onCreateTask?: () => void;
onToggleFilters?: () => void;
onToggleCompactView?: () => void;
onToggleSwimlanes?: () => void;
onNavigatePrevious?: () => void;
onNavigateNext?: () => void;
onGoToToday?: () => void;
onRefresh?: () => void;
onExport?: () => void;
onShowAnalytics?: () => void;
onSave?: () => void;
onBackup?: () => void;
onOpenSearch?: () => void;
}
export function useGlobalKeyboardShortcuts(actions: KeyboardShortcutsActions = {}) {
const pathname = usePathname();
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Éviter les raccourcis si on est dans un input/textarea
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.contentEditable === 'true') {
// Permettre seulement certains raccourcis dans les inputs
if (event.key === 'Escape') {
// Esc pour fermer les modales même dans les inputs
return;
}
// Ignorer les autres raccourcis dans les inputs
return;
}
// Cmd+K - Ouvrir la recherche rapide
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
event.preventDefault();
actions.onOpenSearch?.();
return;
}
// Cmd+N - Créer une nouvelle tâche/élément
if ((event.metaKey || event.ctrlKey) && event.key === 'n') {
event.preventDefault();
actions.onCreateTask?.();
return;
}
// Cmd+F - Ouvrir les filtres (Kanban)
if ((event.metaKey || event.ctrlKey) && event.key === 'f') {
event.preventDefault();
actions.onToggleFilters?.();
return;
}
// Cmd+S - Basculer les swimlanes (Kanban)
if ((event.metaKey || event.ctrlKey) && event.key === 's') {
event.preventDefault();
actions.onToggleSwimlanes?.();
return;
}
// Space - Basculer la vue compacte (Kanban)
if (event.key === ' ' && pathname === '/kanban') {
event.preventDefault();
actions.onToggleCompactView?.();
return;
}
// Cmd+T - Aller à aujourd'hui/cette semaine
if ((event.metaKey || event.ctrlKey) && event.key === 't') {
event.preventDefault();
actions.onGoToToday?.();
return;
}
// Flèches gauche/droite - Navigation
if (event.key === 'ArrowLeft') {
event.preventDefault();
actions.onNavigatePrevious?.();
return;
}
if (event.key === 'ArrowRight') {
event.preventDefault();
actions.onNavigateNext?.();
return;
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [pathname, actions]);
}

View File

@@ -8,7 +8,7 @@ export function useKeyboardShortcuts() {
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
// Cmd + T pour basculer entre light et le thème dark préféré // Cmd + Shift + D pour basculer entre light et le thème dark préféré
if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key === 'D') { if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key === 'D') {
event.preventDefault(); event.preventDefault();
toggleTheme(); toggleTheme();