feature/tabs-navegation
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -26,6 +26,11 @@
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!searchContext) return;
|
||||
searchContext.triggerRef.set(ref);
|
||||
});
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Trigger
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user