diff options
| -rw-r--r-- | src/lib/Layout/Dropdown.svelte | 89 |
1 files changed, 83 insertions, 6 deletions
diff --git a/src/lib/Layout/Dropdown.svelte b/src/lib/Layout/Dropdown.svelte index d27b2590..be35acff 100644 --- a/src/lib/Layout/Dropdown.svelte +++ b/src/lib/Layout/Dropdown.svelte @@ -1,4 +1,6 @@ <script lang="ts"> +import { tick } from "svelte"; + interface Item { name: string; url: string; @@ -12,10 +14,68 @@ export let header = true; export let center = false; let open = false; +let toggleRef: HTMLSpanElement; +let itemRefs: HTMLAnchorElement[] = []; +const instanceId = `dropdown-${Math.random().toString(36).slice(2, 10)}`; +const toggleId = `${instanceId}-toggle`; +const menuId = `${instanceId}-menu`; const handleClickOutside = (event: MouseEvent) => { if (!(event.target as HTMLElement).closest(".dropdown")) open = false; }; + +const focusItem = async (index: number) => { + if (!items.length) return; + + const wrapped = ((index % items.length) + items.length) % items.length; + + await tick(); + itemRefs[wrapped]?.focus(); +}; + +const handleToggleKey = async (e: KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + open = !open; + + if (open) await focusItem(0); + } else if (e.key === "ArrowDown") { + e.preventDefault(); + + if (!open) open = true; + + await focusItem(0); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + + if (!open) open = true; + + await focusItem(-1); + } else if (e.key === "Escape" && open) { + e.preventDefault(); + open = false; + } +}; + +const handleItemKey = async (e: KeyboardEvent, index: number) => { + if (e.key === "Escape") { + e.preventDefault(); + open = false; + toggleRef?.focus(); + } else if (e.key === "ArrowDown") { + e.preventDefault(); + await focusItem(index + 1); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + await focusItem(index - 1); + } else if (e.key === "Home") { + e.preventDefault(); + await focusItem(0); + } else if (e.key === "End") { + e.preventDefault(); + await focusItem(-1); + } +}; </script> <svelte:window onclick={handleClickOutside} /> @@ -28,18 +88,20 @@ const handleClickOutside = (event: MouseEvent) => { };`} > <span + bind:this={toggleRef} class={`${header ? 'header-item' : ''} dropdown-toggle`} - id="dropdown-toggle" + id={toggleId} onclick={(e) => { e.preventDefault(); open = !open; }} - onkeydown={(_e) => { - // if (e.key === 'Enter' || e.key === ' ') open = !open; - }} + onkeydown={handleToggleKey} role="button" tabindex="0" + aria-haspopup="menu" + aria-expanded={open} + aria-controls={menuId} > {#if title} {title} @@ -48,15 +110,24 @@ const handleClickOutside = (event: MouseEvent) => { {/if} </span> - <div class={`dropdown-content card card-small ${open ? 'dropdown-open' : ''}`}> - {#each items as item} + <div + class={`dropdown-content card card-small ${open ? 'dropdown-open' : ''}`} + id={menuId} + role="menu" + aria-labelledby={toggleId} + > + {#each items as item, i} <a + bind:this={itemRefs[i]} href={item.url} class="header-item" + role="menuitem" onclick={(e) => { if (item.preventDefault) e.preventDefault(); if (item.onClick) item.onClick(); + open = false; }} + onkeydown={(e) => handleItemKey(e, i)} > {item.name} </a> @@ -128,6 +199,12 @@ const handleClickOutside = (event: MouseEvent) => { display: block; } + .dropdown-content a:focus-visible { + outline: 2px solid var(--base0D); + outline-offset: 2px; + border-radius: 4px; + } + .dropdown-content a:hover { border-radius: 8px; backdrop-filter: blur(160px); |