feature/tabs-navegation

This commit is contained in:
2026-04-14 13:03:07 -06:00
parent f6ebba2ffb
commit 7d38ac2720
8 changed files with 183 additions and 15 deletions

View File

@@ -8,8 +8,22 @@
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;
@@ -23,7 +37,10 @@
function autoFocusFirstInput() {
// Small delay to allow Svelte to render the new view
setTimeout(() => {
const container = document.getElementById('main-form-content') || document.body;
const container =
document.getElementById('main-form-content') ||
document.getElementById('dashboard-main-content') ||
document.body;
// 1. Try to find active tab panels (Shadcn/Radix uses role="tabpanel" and data-state="active")
const activePanels = container.querySelectorAll('[role="tabpanel"][data-state="active"]');
@@ -159,7 +176,16 @@
return;
}
event.preventDefault();
showHelp = !showHelp;
if (showHelp) {
showHelp = false;
restoreHelpFocus();
} else {
const active = document.activeElement;
if (active instanceof HTMLElement) {
helpFocusRestore = active;
}
showHelp = true;
}
return;
}
@@ -167,6 +193,7 @@
if (key === 'Escape') {
if (showHelp) {
showHelp = false;
restoreHelpFocus();
event.preventDefault();
return;
}
@@ -239,7 +266,10 @@
const route = GLOBAL_NAV[lowerKey as keyof typeof GLOBAL_NAV];
if (route) {
event.preventDefault();
goto(route);
lastShortcutTime = Date.now();
void goto(route).then(() => {
autoFocusFirstInput();
});
return;
}
}
@@ -379,5 +409,11 @@
/>
{#if showHelp}
<ShortcutsHelpModal open={true} onClose={() => (showHelp = false)} />
<ShortcutsHelpModal
open={true}
onClose={() => {
showHelp = false;
restoreHelpFocus();
}}
/>
{/if}

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { tick } from 'svelte';
import { GLOBAL_NAV as GLOBAL_CONF } from '$lib/config/shortcuts';
import { activeShortcuts as store } from '$lib/stores/shortcut-store';
@@ -11,6 +12,29 @@
let localList = $state<HTMLDivElement | undefined>();
let modalRef = $state<HTMLDivElement | undefined>();
const SHORTCUTS_HELP_TITLE_ID = 'shortcuts-help-dialog-title';
function isFocusableVisible(el: HTMLElement): boolean {
if (el.hasAttribute('disabled')) return false;
if (el.tabIndex === -1) return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
$effect(() => {
if (!open) return;
let cancelled = false;
tick().then(() => {
requestAnimationFrame(() => {
if (cancelled) return;
globalList?.focus();
});
});
return () => {
cancelled = true;
};
});
function handleArrowScroll(event: KeyboardEvent, target: HTMLDivElement | undefined) {
if (!target) return;
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
@@ -25,9 +49,7 @@
const focusable = modalRef.querySelectorAll<HTMLElement>(
'button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])'
);
const focusables = Array.from(focusable).filter(
(el) => !el.hasAttribute('disabled') && el.tabIndex !== -1 && el.offsetParent !== null
);
const focusables = Array.from(focusable).filter((el) => isFocusableVisible(el));
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
@@ -48,6 +70,7 @@
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-labelledby={SHORTCUTS_HELP_TITLE_ID}
>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
@@ -60,7 +83,7 @@
class="mb-6 flex items-center justify-between border-b border-gray-200 pb-4 dark:border-gray-800"
>
<div>
<h2 class="text-xl font-bold">Keyboard Shortcuts</h2>
<h2 id={SHORTCUTS_HELP_TITLE_ID} class="text-xl font-bold">Keyboard Shortcuts</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">
Context: <span class="font-medium text-blue-600 dark:text-blue-400"
>{$store.context}</span
@@ -91,11 +114,11 @@
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="max-h-64 space-y-2 overflow-y-auto pr-1"
class="max-h-64 space-y-2 overflow-y-auto rounded pr-1 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
role="region"
aria-label="Global Navigation shortcuts"
bind:this={globalList}
tabindex="-1"
tabindex="0"
onkeydown={(event) => handleArrowScroll(event, globalList)}
>
{#each Object.entries(GLOBAL_CONF) as [key, route]}
@@ -131,11 +154,11 @@
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="max-h-64 space-y-2 overflow-y-auto pr-1"
class="max-h-64 space-y-2 overflow-y-auto rounded pr-1 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
role="region"
aria-label="Active Actions shortcuts"
bind:this={localList}
tabindex="-1"
tabindex="0"
onkeydown={(event) => handleArrowScroll(event, localList)}
>
{#each localShortcuts as shortcut}

View File

@@ -4,6 +4,7 @@
import SelectScrollDownButton from "./select-scroll-down-button.svelte";
import { cn, type WithoutChild } from "$lib/utils.js";
import { getContext } from "svelte";
import { get } from "svelte/store";
import { selectSearchContextKey, type SelectSearchContext } from "./select-search-context";
let {
@@ -27,6 +28,7 @@
let searchQuery = $state('');
let isOpen = $state(false);
let searchInputRef = $state<HTMLInputElement | null>(null);
let prevOpen = $state(false);
$effect(() => {
if (!searchContext) return;
@@ -50,6 +52,24 @@
searchInputRef.select();
});
/** bits-ui hace preventDefault en onCloseAutoFocus; devolvemos foco al trigger de este Select. */
$effect(() => {
if (!searchContext) return;
const justClosed = prevOpen && !isOpen;
prevOpen = isOpen;
if (!justClosed) return;
queueMicrotask(() => {
const trigger = get(searchContext.triggerRef);
if (trigger && document.contains(trigger)) {
try {
trigger.focus();
} catch {
/* ignore */
}
}
});
});
function updateQuery(next: string) {
searchQuery = next;
searchContext?.query.set(next);
@@ -62,6 +82,57 @@
searchInputRef.focus();
}
}
/** Despacha un KeyboardEvent al trigger de bits-ui usando el contexto de búsqueda. */
function dispatchToTrigger(key: string) {
const trigger = searchContext ? get(searchContext.triggerRef) : null;
if (!trigger) return;
trigger.dispatchEvent(
new KeyboardEvent('keydown', { key, code: key, bubbles: true, cancelable: true })
);
}
/**
* Selecciona el ítem resaltado (o el primero si ninguno lo está) usando la ruta
* de teclado de bits-ui: TriggerState.onkeydown → #handleKeyboardSelection → toggleItem.
*
* Se evitan PointerEvents sintéticos porque onpointerup verifica opts.ref.current,
* que es un Attachment y puede estar en estado inconsistente durante el despacho.
*/
function selectHighlightedItem() {
requestAnimationFrame(() => {
// Si no hay ítem resaltado todavía, bits-ui resalta el primero síncronamente con ArrowDown
const hasHighlighted = !!ref?.querySelector('[data-highlighted]');
if (!hasHighlighted) {
dispatchToTrigger('ArrowDown');
}
// TriggerState.onkeydown(Enter) → #handleKeyboardSelection → toggleItem → handleClose
dispatchToTrigger('Enter');
});
}
/** Maneja teclado en el buscador: Tab cierra, flechas navegan, Enter selecciona, resto filtra. */
function handleSearchInputKeydown(e: KeyboardEvent) {
if (e.key === 'Tab') {
// Cierra el dropdown y deja que Tab mueva el foco al campo siguiente naturalmente
searchContext?.setOpen(false);
return;
}
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
e.stopPropagation();
dispatchToTrigger(e.key);
return;
}
if (e.key === 'Enter' && !e.isComposing) {
e.preventDefault();
e.stopPropagation();
selectHighlightedItem();
return;
}
// Detener burbujeo de caracteres para que handleContentKeydown no robe el foco
e.stopPropagation();
}
</script>
<SelectPrimitive.Portal {...portalProps}>
@@ -88,7 +159,7 @@
class="placeholder:text-muted-foreground flex h-8 w-full rounded-md border border-input bg-background px-3 py-1 text-sm outline-none focus:border-ring focus:ring-1 focus:ring-ring"
oninput={(event) => updateQuery((event.currentTarget as HTMLInputElement).value)}
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
onkeydown={handleSearchInputKeydown}
/>
</div>
{/if}

View File

@@ -24,9 +24,11 @@
let open = $state(false);
const query = writable('');
const openStore = writable(false);
const triggerRef = writable<HTMLElement | null>(null);
const context: SelectSearchContext = {
query,
open: openStore,
triggerRef,
setOpen: (next) => {
open = next;
}

View File

@@ -6,4 +6,6 @@ export type SelectSearchContext = {
query: Writable<string>;
open: Writable<boolean>;
setOpen: (next: boolean) => void;
/** Nodo del trigger para devolver foco al cerrar (bits-ui bloquea onCloseAutoFocus por defecto). */
triggerRef: Writable<HTMLElement | null>;
};

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { tick } from 'svelte';
import { Combobox as ComboboxPrimitive } from 'bits-ui';
import { ChevronDown, X } from 'lucide-svelte';
import { cn } from '$lib/utils.js';
@@ -32,6 +33,7 @@
let open = $state(false);
let isTyping = $state(false);
let containerWidth = $state(0);
let inputEl: HTMLInputElement | null = $state(null);
// Get selected item label
const selectedLabel = $derived((items ?? []).find((item) => item.value === value)?.label || '');
@@ -48,11 +50,33 @@
)
);
function handleValueChange(v: string) {
async function handleValueChange(v: string) {
value = v;
onValueChange?.(v);
open = false;
isTyping = false;
await tick();
inputEl?.focus();
}
function handleKeydown(e: KeyboardEvent) {
if (!open) return;
if (e.key === 'Tab') {
// Close the dropdown and let Tab move focus naturally
open = false;
isTyping = false;
return;
}
if (e.key === 'Enter') {
// If bits-ui already has a highlighted item, let it handle selection
const highlighted = document.querySelector('[data-highlighted]');
if (highlighted) return;
// No highlighted item — select the first visible item
if (filteredItems.length > 0) {
e.preventDefault();
handleValueChange(filteredItems[0].value);
}
}
}
function handleClear(e: Event) {
@@ -94,6 +118,7 @@
<div class="relative {className}" bind:clientWidth={containerWidth}>
<div class="relative">
<ComboboxPrimitive.Input
bind:ref={inputEl}
bind:value={searchQuery}
{placeholder}
aria-label={placeholder}
@@ -103,6 +128,7 @@
)}
oninput={handleInput}
onfocus={handleFocus}
onkeydown={handleKeydown}
/>
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
{#if value && !disabled}

View File

@@ -26,6 +26,11 @@
event.preventDefault();
}
}
$effect(() => {
if (!searchContext) return;
searchContext.triggerRef.set(ref);
});
</script>
<SelectPrimitive.Trigger

View File

@@ -141,7 +141,10 @@
-->
</div>
</header>
<div class="flex flex-1 flex-col min-h-0 gap-4 overflow-y-auto overflow-x-hidden p-4 pt-0">
<div
id="dashboard-main-content"
class="flex flex-1 flex-col min-h-0 gap-4 overflow-y-auto overflow-x-hidden p-4 pt-0"
>
{#if csvImportBanner}
<a
href="/dashboard/csv-upload"