hotfix/modal-roles
This commit is contained in:
@@ -1,170 +1,165 @@
|
||||
<script lang="ts">
|
||||
import { GLOBAL_NAV as GLOBAL_CONF } from '$lib/config/shortcuts';
|
||||
import { activeShortcuts as store } from '$lib/stores/shortcut-store';
|
||||
|
||||
let { open = false, onClose } = $props();
|
||||
|
||||
// Group local shortcuts
|
||||
let localShortcuts = $derived($store.shortcuts);
|
||||
|
||||
let globalList = $state<HTMLDivElement | undefined>();
|
||||
let localList = $state<HTMLDivElement | undefined>();
|
||||
let modalRef = $state<HTMLDivElement | undefined>();
|
||||
|
||||
function handleArrowScroll(event: KeyboardEvent, target: HTMLDivElement | undefined) {
|
||||
if (!target) return;
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
const delta = event.key === 'ArrowDown' ? 32 : -32;
|
||||
target.scrollTop += delta;
|
||||
}
|
||||
}
|
||||
|
||||
function handleFocusTrap(event: KeyboardEvent) {
|
||||
if (event.key !== 'Tab' || !modalRef) return;
|
||||
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
|
||||
);
|
||||
if (focusables.length === 0) return;
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
const active = document.activeElement as HTMLElement;
|
||||
|
||||
if (!event.shiftKey && active === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
} else if (event.shiftKey && active === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-2xl rounded-xl bg-white p-6 shadow-2xl dark:bg-gray-900 text-gray-900 dark:text-gray-100 max-h-[80vh] overflow-y-auto"
|
||||
role="document"
|
||||
bind:this={modalRef}
|
||||
role="presentation"
|
||||
onkeydown={handleFocusTrap}
|
||||
>
|
||||
<div
|
||||
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>
|
||||
<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
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
<button onclick={onClose} class="rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||
<span class="sr-only">Close</span>
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/></svg
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-8">
|
||||
<!-- Global Navigation -->
|
||||
<div>
|
||||
<h3
|
||||
class="mb-3 text-sm font-semibold tracking-wider text-gray-500 uppercase dark:text-gray-400"
|
||||
>
|
||||
Global Navigation (Alt)
|
||||
</h3>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="space-y-2 max-h-64 overflow-y-auto pr-1"
|
||||
role="region"
|
||||
aria-label="Global Navigation shortcuts"
|
||||
bind:this={globalList}
|
||||
tabindex="-1"
|
||||
role="region"
|
||||
aria-label="Global Navigation Shortcuts"
|
||||
onkeydown={(event) => handleArrowScroll(event, globalList)}
|
||||
>
|
||||
{#each Object.entries(GLOBAL_CONF) as [key, route]}
|
||||
<div
|
||||
class="flex items-center justify-between rounded bg-gray-50 px-3 py-2 dark:bg-gray-800/50"
|
||||
>
|
||||
<span class="text-sm font-medium"
|
||||
>{key === 'b'
|
||||
? 'Search'
|
||||
: route === '/'
|
||||
? 'Home'
|
||||
: 'Go to ' + route.split('/').pop()}</span
|
||||
>
|
||||
<kbd
|
||||
class="rounded border border-gray-200 bg-white px-2 py-0.5 text-xs font-bold text-gray-700 shadow-sm dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
|
||||
>Alt + {key.toUpperCase()}</kbd
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Local Actions -->
|
||||
<div>
|
||||
<h3
|
||||
class="mb-3 text-sm font-semibold tracking-wider text-gray-500 uppercase dark:text-gray-400"
|
||||
>
|
||||
Active Actions (Alt+Shift)
|
||||
</h3>
|
||||
{#if localShortcuts.length === 0}
|
||||
<p class="text-sm text-gray-400 italic">No specific actions for this view.</p>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="space-y-2 max-h-64 overflow-y-auto pr-1"
|
||||
role="region"
|
||||
aria-label="Active Actions shortcuts"
|
||||
bind:this={localList}
|
||||
tabindex="-1"
|
||||
role="region"
|
||||
aria-label="Local Action Shortcuts"
|
||||
onkeydown={(event) => handleArrowScroll(event, localList)}
|
||||
>
|
||||
{#each localShortcuts as shortcut}
|
||||
<div
|
||||
class="flex items-center justify-between rounded bg-blue-50 px-3 py-2 dark:bg-blue-900/20"
|
||||
>
|
||||
<span class="text-sm font-medium text-blue-900 dark:text-blue-100"
|
||||
>{shortcut.description}</span
|
||||
>
|
||||
<kbd
|
||||
class="rounded border border-blue-200 bg-white px-2 py-0.5 text-xs font-bold text-blue-700 shadow-sm dark:border-blue-800 dark:bg-gray-900 dark:text-blue-300"
|
||||
>{shortcut.key}</kbd
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex justify-end border-t border-gray-200 pt-4 dark:border-gray-800">
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="rounded bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800 dark:bg-white dark:text-gray-900"
|
||||
>Close</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<script lang="ts">
|
||||
import { GLOBAL_NAV as GLOBAL_CONF } from '$lib/config/shortcuts';
|
||||
import { activeShortcuts as store } from '$lib/stores/shortcut-store';
|
||||
|
||||
let { open = false, onClose } = $props();
|
||||
|
||||
// Group local shortcuts
|
||||
let localShortcuts = $derived($store.shortcuts);
|
||||
|
||||
let globalList = $state<HTMLDivElement | undefined>();
|
||||
let localList = $state<HTMLDivElement | undefined>();
|
||||
let modalRef = $state<HTMLDivElement | undefined>();
|
||||
|
||||
function handleArrowScroll(event: KeyboardEvent, target: HTMLDivElement | undefined) {
|
||||
if (!target) return;
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
const delta = event.key === 'ArrowDown' ? 32 : -32;
|
||||
target.scrollTop += delta;
|
||||
}
|
||||
}
|
||||
|
||||
function handleFocusTrap(event: KeyboardEvent) {
|
||||
if (event.key !== 'Tab' || !modalRef) return;
|
||||
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
|
||||
);
|
||||
if (focusables.length === 0) return;
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
const active = document.activeElement as HTMLElement;
|
||||
|
||||
if (!event.shiftKey && active === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
} else if (event.shiftKey && active === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-2xl rounded-xl bg-white p-6 shadow-2xl dark:bg-gray-900 text-gray-900 dark:text-gray-100 max-h-[80vh] overflow-y-auto"
|
||||
role="document"
|
||||
bind:this={modalRef}
|
||||
onkeydown={handleFocusTrap}
|
||||
>
|
||||
<div
|
||||
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>
|
||||
<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
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
<button onclick={onClose} class="rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||
<span class="sr-only">Close</span>
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/></svg
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-8">
|
||||
<!-- Global Navigation -->
|
||||
<div>
|
||||
<h3
|
||||
class="mb-3 text-sm font-semibold tracking-wider text-gray-500 uppercase dark:text-gray-400"
|
||||
>
|
||||
Global Navigation (Alt)
|
||||
</h3>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="space-y-2 max-h-64 overflow-y-auto pr-1"
|
||||
role="region"
|
||||
aria-label="Global Navigation shortcuts"
|
||||
bind:this={globalList}
|
||||
tabindex="-1"
|
||||
onkeydown={(event) => handleArrowScroll(event, globalList)}
|
||||
>
|
||||
{#each Object.entries(GLOBAL_CONF) as [key, route]}
|
||||
<div
|
||||
class="flex items-center justify-between rounded bg-gray-50 px-3 py-2 dark:bg-gray-800/50"
|
||||
>
|
||||
<span class="text-sm font-medium"
|
||||
>{key === 'b'
|
||||
? 'Search'
|
||||
: route === '/'
|
||||
? 'Home'
|
||||
: 'Go to ' + route.split('/').pop()}</span
|
||||
>
|
||||
<kbd
|
||||
class="rounded border border-gray-200 bg-white px-2 py-0.5 text-xs font-bold text-gray-700 shadow-sm dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
|
||||
>Alt + {key.toUpperCase()}</kbd
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Local Actions -->
|
||||
<div>
|
||||
<h3
|
||||
class="mb-3 text-sm font-semibold tracking-wider text-gray-500 uppercase dark:text-gray-400"
|
||||
>
|
||||
Active Actions (Alt+Shift)
|
||||
</h3>
|
||||
{#if localShortcuts.length === 0}
|
||||
<p class="text-sm text-gray-400 italic">No specific actions for this view.</p>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="space-y-2 max-h-64 overflow-y-auto pr-1"
|
||||
role="region"
|
||||
aria-label="Active Actions shortcuts"
|
||||
bind:this={localList}
|
||||
tabindex="-1"
|
||||
onkeydown={(event) => handleArrowScroll(event, localList)}
|
||||
>
|
||||
{#each localShortcuts as shortcut}
|
||||
<div
|
||||
class="flex items-center justify-between rounded bg-blue-50 px-3 py-2 dark:bg-blue-900/20"
|
||||
>
|
||||
<span class="text-sm font-medium text-blue-900 dark:text-blue-100"
|
||||
>{shortcut.description}</span
|
||||
>
|
||||
<kbd
|
||||
class="rounded border border-blue-200 bg-white px-2 py-0.5 text-xs font-bold text-blue-700 shadow-sm dark:border-blue-800 dark:bg-gray-900 dark:text-blue-300"
|
||||
>{shortcut.key}</kbd
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex justify-end border-t border-gray-200 pt-4 dark:border-gray-800">
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="rounded bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800 dark:bg-white dark:text-gray-900"
|
||||
>Close</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user