543 lines
17 KiB
Svelte
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}
|