Files
plantillas-proyectos/frontend/src/lib/components/keyboard/KeyboardManager.svelte
2026-04-15 10:23:21 -06:00

543 lines
17 KiB
Svelte

<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { browser } from '$app/environment';
import { GLOBAL_NAV } from '$lib/config/shortcuts';
import { shortcutStore, activeShortcutsList } from '$lib/stores/shortcut-store';
import { focusStore, interactionMode } from '$lib/stores/focus-store';
import ShortcutsHelpModal from './ShortcutsHelpModal.svelte';
let showHelp = $state(false);
/** Element to restore focus when closing the F1 shortcuts help modal */
let helpFocusRestore: HTMLElement | null = null;
let lastShortcutTime = 0;
function restoreHelpFocus() {
const el = helpFocusRestore;
helpFocusRestore = null;
if (el && document.contains(el) && typeof el.focus === 'function') {
try {
el.focus();
} catch {
/* ignore e.g. disconnected nodes */
}
}
}
// Verificar si el usuario está autenticado
function isAuthenticated(): boolean {
if (!browser) return false;
const currentPath = $page?.url?.pathname || '';
const isAuthenticatedRoute = currentPath.startsWith('/dashboard');
const hasAccessToken =
document.cookie.includes('access_token=') || localStorage.getItem('access_token');
return isAuthenticatedRoute && !!hasAccessToken;
}
function isVisibleInMainContent(htmlEl: HTMLElement): boolean {
if (htmlEl.closest('[data-sidebar="sidebar"]')) return false;
return !!(htmlEl.offsetWidth || htmlEl.offsetHeight || htmlEl.getClientRects().length);
}
function inputIsSearchCandidate(el: HTMLInputElement): boolean {
const t = (el.type || 'text').toLowerCase();
return !['hidden', 'checkbox', 'radio', 'file', 'button', 'submit', 'reset'].includes(t);
}
/**
* Alt+B: focus the current view's primary filter/search when possible.
* Order: [data-view-search] → placeholder hints (buscar/search/…) →
* first visible text-like input before the main table / catalog scroll → global sr-only fallback.
*/
function focusViewSearchOrGlobal() {
const main =
document.getElementById('dashboard-main-content') ||
document.getElementById('main-form-content');
const tryFocus = (el: HTMLElement | null | undefined): boolean => {
if (!el || !isVisibleInMainContent(el)) return false;
if (el.closest('[role="dialog"]')) return false;
try {
el.focus();
} catch {
return false;
}
setTimeout(() => {
el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
}, 50);
return true;
};
if (main) {
const explicit = main.querySelector<HTMLElement>('[data-view-search]:not([disabled])');
if (tryFocus(explicit)) return;
const phPattern = /buscar|search|filtrar|filter|búsqueda|busqueda/i;
const inputs = Array.from(
main.querySelectorAll<HTMLInputElement>('input:not([disabled])')
).filter(inputIsSearchCandidate);
for (const input of inputs) {
if (!isVisibleInMainContent(input)) continue;
if (input.closest('[role="dialog"]')) continue;
if (phPattern.test(input.placeholder || '')) {
if (tryFocus(input)) return;
}
}
const boundary = main.querySelector(
'table, tbody, .catalog-table-scroll, [data-slot="table-container"]'
);
if (boundary) {
for (const input of inputs) {
if (!isVisibleInMainContent(input)) continue;
if (input.closest('[role="dialog"]')) continue;
if (input.compareDocumentPosition(boundary) & Node.DOCUMENT_POSITION_FOLLOWING) {
if (tryFocus(input)) return;
}
}
}
}
document.getElementById('global-search-input')?.focus();
}
/** First meaningful control in main area (forms); optional delay for post-navigation paint. */
function focusMainContentPrimary(options?: { delay?: number }) {
const delay = options?.delay ?? 350;
setTimeout(() => {
const container =
document.getElementById('main-form-content') ||
document.getElementById('dashboard-main-content') ||
document.body;
const activePanels = container.querySelectorAll('[role="tabpanel"][data-state="active"]');
const searchableAreas =
activePanels.length > 0 ? Array.from(activePanels).reverse() : [container];
for (const area of searchableAreas) {
const focusables = area.querySelectorAll(
'input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]):not([role="tab"])'
);
const firstVisible = Array.from(focusables).find((el) =>
isVisibleInMainContent(el as HTMLElement)
) as HTMLElement | undefined;
if (firstVisible) {
firstVisible.focus();
setTimeout(() => {
firstVisible.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
}, 100);
return;
}
}
const fallbacks = container.querySelectorAll(
'tr[tabindex="0"], [data-slot="table-row"][tabindex="0"], a[href]:not([tabindex="-1"]), button:not([disabled]):not([role="tab"]):not([tabindex="-1"])'
);
const fb = Array.from(fallbacks).find((el) =>
isVisibleInMainContent(el as HTMLElement)
) as HTMLElement | undefined;
if (fb) {
fb.focus();
setTimeout(() => {
fb.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
}, 100);
}
}, delay);
}
function focusSidebarNav() {
window.dispatchEvent(new CustomEvent('anexo76:expand-sidebar-for-nav'));
setTimeout(() => {
const nav = document.getElementById('dashboard-sidebar-nav');
if (!nav) return;
const candidates = nav.querySelectorAll(
'a[href]:not([tabindex="-1"]), button:not([disabled]):not([tabindex="-1"])'
);
const first = Array.from(candidates).find(
(el) => !(el as HTMLElement).closest('[data-slot="sidebar-rail"]')
) as HTMLElement | undefined;
if (first) {
first.focus();
setTimeout(() => {
first.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, 50);
}
}, 350);
}
function focusTriggerByDescription(description: string) {
// Small delay to allow Svelte to update the DOM
setTimeout(() => {
// Extract a meaningful keyword from the description (e.g., "Go to DTA")
const words = description.split(' ');
// Look for words that are uppercase or the last word
const keyword =
words.find((w) => w.length >= 3 && w === w.toUpperCase()) || words[words.length - 1];
if (!keyword) return;
// Find buttons or tabs containing that text
const elements = document.querySelectorAll('button, [role="tab"], a');
const target = Array.from(elements).find((el) => {
const text = el.textContent?.trim() || '';
// Exclude sidebar
if (el.closest('[data-sidebar="sidebar"]')) return false;
return text.toLowerCase().includes(keyword.toLowerCase());
}) as HTMLElement;
if (target) {
target.focus();
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 100);
}
// FOCUS POLICY DICTIONARY
// Decides what to focus after a shortcut/navigation based on context
const FOCUS_POLICY: Record<string, 'trigger' | 'first-input'> = {
// Pedimentos
'General Tab Navigation': 'trigger',
'Pedimento Edit Main Tabs': 'first-input',
'Otros Tab Navigation': 'first-input',
'Contribuciones Tab Navigation': 'first-input',
// Dashboard forms with Alt+Digit tab navigation
'Edit Broker Tabs': 'first-input',
'Edit Client Provider': 'first-input',
'Formulario Empresa': 'first-input',
'Formulario DODA': 'first-input',
'Invoice Edit': 'first-input',
'Part Form': 'first-input',
'Invoice Item Form (Inventory)': 'first-input',
'Invoice Item Form (Fixed Asset)': 'first-input',
'Customs Brokers List': 'trigger',
'Clients Providers List': 'trigger'
};
// Listen for remote focus requests (e.g., from mouse navigation)
$effect(() => {
if ($focusStore) {
// If a shortcut was just pressed, ignore remote focus requests for 250ms
// to allow the shortcut's specific focus strategy to prevail
if (Date.now() - lastShortcutTime < 250) return;
if ($focusStore.strategy === 'first-input') {
focusMainContentPrimary();
} else if ($focusStore.strategy === 'trigger' && $focusStore.description) {
focusTriggerByDescription($focusStore.description);
}
}
});
function handleKeydown(event: KeyboardEvent) {
// Verificar autenticación al inicio
const authenticated = isAuthenticated();
const { key, altKey, ctrlKey, metaKey, shiftKey } = event;
if (!key) return;
const lowerKey = key.toLowerCase();
// Ignore standalone modifiers
if (['Control', 'Alt', 'Shift', 'Meta'].includes(key)) return;
// Construct current combo string: "Alt+Shift+K"
const modifiers = [];
if (ctrlKey) modifiers.push('Ctrl');
if (altKey) modifiers.push('Alt');
if (shiftKey) modifiers.push('Shift');
if (metaKey) modifiers.push('Meta');
const combo = [...modifiers, key.toUpperCase()].join('+');
// Input Guard
const target = event.target as HTMLElement;
const isInput =
target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
// 0. SEARCH TO TABLE FLOW: ArrowDown from search input to first table row
if (isInput && key === 'ArrowDown') {
const dialog = target.closest('[role="dialog"]');
if (dialog) {
const firstRow = dialog.querySelector(
'tr[tabindex="0"], [data-slot$="-item"][tabindex="0"]'
) as HTMLElement;
if (firstRow) {
event.preventDefault();
firstRow.focus();
return;
}
}
}
// 1. HELP: F1
if (key === 'F1') {
// Solo permitir F1 si está autenticado
if (!isAuthenticated()) {
return;
}
event.preventDefault();
if (showHelp) {
showHelp = false;
restoreHelpFocus();
} else {
const active = document.activeElement;
if (active instanceof HTMLElement) {
helpFocusRestore = active;
}
showHelp = true;
}
return;
}
// 2. ESCAPE
if (key === 'Escape') {
if (showHelp) {
showHelp = false;
restoreHelpFocus();
event.preventDefault();
return;
}
// specific search for Escape in REVERSE order (LIFO)
const match = [...$activeShortcutsList].reverse().find((s) => s.key === 'Escape');
if (match) {
event.preventDefault();
match.action();
return;
}
}
// 3. PRIORITY: LOCAL SHORTCUTS
// Solo permitir atajos locales si está autenticado
if (authenticated) {
// Match exact combo string against registered shortcuts
// Search in REVERSE order (LIFO) so recent contexts (modals) override earlier ones (pages)
const reversedShortcuts = [...$activeShortcutsList].reverse();
let match = reversedShortcuts.find((s) => s.key === combo);
// Fallback: If no match found by 'key' (e.g. Shift+1 produces 'Alt+Shift+!'),
// try matching by 'code' (e.g. 'Alt+Shift+Digit1')
if (!match) {
const codeCombo = [...modifiers, event.code].join('+');
match = reversedShortcuts.find((s) => s.key === codeCombo);
}
if (match) {
// Guard: If it's a single key (no modifiers) and we are in input, ignore it
// Unless the shortcut explicitly says "allowInInput" (not implemented yet, assuming strictly no inputs for single keys)
if (modifiers.length === 0 && isInput) {
// allow native typing
return;
}
event.preventDefault();
event.stopPropagation(); // Stop bubbling
lastShortcutTime = Date.now();
match.action();
// Hybrid focus strategy
if (combo.includes('Alt+') && match) {
const isDigit = combo.includes('Digit') || (event.code && event.code.startsWith('Digit'));
const strategy = FOCUS_POLICY[match.context] || 'first-input';
if (isDigit && strategy === 'trigger') {
// Focus the trigger for specific views (General sub-tabs)
focusTriggerByDescription(match.description);
} else {
// Default: Focus the first input (Main tabs, non-trigger sub-tabs)
focusMainContentPrimary();
}
}
return;
}
} // Fin del bloque authenticated
// 4. GLOBAL NAV: Alt + Key (Legacy/Default behavior for global nav)
// Solo permitir si está autenticado
if (authenticated && altKey && modifiers.length === 1) {
// Exactly Alt + Key (no shift/ctrl)
// Special: Alt+B -> Focus view search / filters or global fallback
if (lowerKey === 'b') {
event.preventDefault();
lastShortcutTime = Date.now();
focusViewSearchOrGlobal();
return;
}
if (lowerKey === 'n') {
event.preventDefault();
lastShortcutTime = Date.now();
focusSidebarNav();
return;
}
if (lowerKey === 'j') {
event.preventDefault();
lastShortcutTime = Date.now();
focusMainContentPrimary({ delay: 0 });
return;
}
const route = GLOBAL_NAV[lowerKey as keyof typeof GLOBAL_NAV];
if (
route &&
route !== 'SEARCH_FOCUS' &&
route !== 'SIDEBAR_FOCUS' &&
route !== 'MAIN_CONTENT_FOCUS'
) {
event.preventDefault();
lastShortcutTime = Date.now();
void goto(route).then(() => {
focusMainContentPrimary();
});
return;
}
}
// 5. GLOBAL TABLE & SELECT NAVIGATION: Arrows / Enter / Tab
const isRowOrItem =
target.tagName === 'TR' ||
target.getAttribute('data-slot') === 'table-row' ||
target.getAttribute('data-slot') === 'select-item' ||
target.closest('[data-slot="table-row"]') ||
target.closest('[data-slot="select-item"]');
if (isRowOrItem) {
const element = (
target.getAttribute('data-slot')?.includes('-item') || target.tagName === 'TR'
? target
: target.closest('[data-slot$="-row"], [data-slot$="-item"]')
) as HTMLElement;
if (!element) return;
// Logic for UP/DOWN arrows
if (key === 'ArrowDown') {
const next = element.nextElementSibling as HTMLElement;
if (next) {
event.preventDefault();
next.focus();
}
} else if (key === 'ArrowUp') {
const prev = element.previousElementSibling as HTMLElement;
if (prev) {
event.preventDefault();
prev.focus();
}
} else if (key === 'Enter' || key === ' ') {
if (target.tagName !== 'INPUT' && target.tagName !== 'TEXTAREA') {
event.preventDefault();
element.click();
}
} else if (key === 'Tab') {
// Custom Tab navigation for rows/items if requested
const siblings = Array.from(element.parentElement?.children || []);
const index = siblings.indexOf(element);
if (shiftKey) {
if (index > 0) {
event.preventDefault();
(siblings[index - 1] as HTMLElement).focus();
}
} else {
if (index < siblings.length - 1) {
event.preventDefault();
(siblings[index + 1] as HTMLElement).focus();
}
}
}
}
}
function handleFocusIn(event: FocusEvent) {
const target = event.target as HTMLElement;
if (!target) return;
// Exclude sidebar from any magic scrolling
if (target.closest('[data-sidebar="sidebar"]')) return;
// Exclude checkboxes inside tables (e.g. row selection) - prevents unwanted scroll when selecting
if (
target.tagName === 'INPUT' &&
(target as HTMLInputElement).type === 'checkbox' &&
target.closest('table')
) {
return;
}
// Exclude table rows (TR) - they have tabindex for keyboard nav but clicking to select shouldn't scroll
if (target.tagName === 'TR' && target.closest('table')) {
return;
}
// Check if it's an interactive element we care about
const isInteractive =
['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'A'].includes(target.tagName) ||
target.role === 'tab';
if (isInteractive) {
// Force scroll to center after a delay to override browser default behavior
setTimeout(() => {
target.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
}, 100);
}
}
// --- DYNAMIC INTERACTIVE OBSERVER ---
// Automatically make any clickable row or item focusable
$effect(() => {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node instanceof HTMLElement) {
// Find TRs that look like they are clickable/interactive
const trs = node.tagName === 'TR' ? [node] : Array.from(node.querySelectorAll('tr'));
trs.forEach((tr) => {
// If it has a cursor-pointer class or an onclick handler (complex to detect in Svelte,
// so we use a broad heuristic: any TR inside a dialog's scroll area)
const isInSelectionDialog =
!!tr.closest('[role="dialog"]') &&
!!tr.closest('.overflow-y-auto, .overflow-auto');
if (isInSelectionDialog && !tr.hasAttribute('tabindex')) {
tr.setAttribute('tabindex', '0');
// Add focus styles if it's a native TR
if (!tr.getAttribute('data-slot')) {
tr.classList.add(
'focus-visible:outline-none',
'focus-visible:bg-accent',
'focus-visible:ring-1',
'focus-visible:ring-ring'
);
}
}
});
}
});
});
});
observer.observe(document.body, { childList: true, subtree: true });
return () => observer.disconnect();
});
</script>
<svelte:window
onkeydown={(e) => {
interactionMode.set('keyboard');
handleKeydown(e);
}}
onmousedown={() => interactionMode.set('mouse')}
onfocusin={handleFocusIn}
/>
{#if showHelp}
<ShortcutsHelpModal
open={true}
onClose={() => {
showHelp = false;
restoreHelpFocus();
}}
/>
{/if}