aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/lib/Layout/Dropdown.svelte89
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);