Spaces:
Build error
Build error
Improve modal a11y (#177)
Browse files* add Portal component so we can render modals at root for focus trap
* make modals closeable with ESC + click outside + focus trap
* remove unecessary check on inert prop
Co-authored-by: Eliott C. <[email protected]>
---------
Co-authored-by: Eliott C. <[email protected]>
src/app.html
CHANGED
|
@@ -20,7 +20,7 @@
|
|
| 20 |
%sveltekit.head%
|
| 21 |
</head>
|
| 22 |
<body data-sveltekit-preload-data="hover" class="h-full dark:bg-gray-900">
|
| 23 |
-
<div class="contents h-full">%sveltekit.body%</div>
|
| 24 |
|
| 25 |
<!-- Google Tag Manager -->
|
| 26 |
<script>
|
|
|
|
| 20 |
%sveltekit.head%
|
| 21 |
</head>
|
| 22 |
<body data-sveltekit-preload-data="hover" class="h-full dark:bg-gray-900">
|
| 23 |
+
<div id="app" class="contents h-full">%sveltekit.body%</div>
|
| 24 |
|
| 25 |
<!-- Google Tag Manager -->
|
| 26 |
<script>
|
src/lib/components/Modal.svelte
CHANGED
|
@@ -1,15 +1,59 @@
|
|
| 1 |
<script lang="ts">
|
|
|
|
| 2 |
import { cubicOut } from "svelte/easing";
|
| 3 |
import { fade } from "svelte/transition";
|
|
|
|
|
|
|
| 4 |
|
| 5 |
export let width = "max-w-sm";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
</script>
|
| 7 |
|
| 8 |
-
<
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
</div>
|
| 15 |
-
</
|
|
|
|
| 1 |
<script lang="ts">
|
| 2 |
+
import { createEventDispatcher, onDestroy, onMount } from "svelte";
|
| 3 |
import { cubicOut } from "svelte/easing";
|
| 4 |
import { fade } from "svelte/transition";
|
| 5 |
+
import Portal from "./Portal.svelte";
|
| 6 |
+
import { browser } from "$app/environment";
|
| 7 |
|
| 8 |
export let width = "max-w-sm";
|
| 9 |
+
|
| 10 |
+
let backdropEl: HTMLDivElement;
|
| 11 |
+
let modalEl: HTMLDivElement;
|
| 12 |
+
|
| 13 |
+
const dispatch = createEventDispatcher<{ close: void }>();
|
| 14 |
+
|
| 15 |
+
function handleKeydown(event: KeyboardEvent) {
|
| 16 |
+
// close on ESC
|
| 17 |
+
if (event.key === "Escape") {
|
| 18 |
+
event.preventDefault();
|
| 19 |
+
dispatch("close");
|
| 20 |
+
}
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
function handleBackdropClick(event: MouseEvent) {
|
| 24 |
+
if (event.target === backdropEl) {
|
| 25 |
+
dispatch("close");
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
onMount(() => {
|
| 30 |
+
document.getElementById("app")?.setAttribute("inert", "true");
|
| 31 |
+
modalEl.focus();
|
| 32 |
+
});
|
| 33 |
+
|
| 34 |
+
onDestroy(() => {
|
| 35 |
+
if (!browser) return;
|
| 36 |
+
document.getElementById("app")?.removeAttribute("inert");
|
| 37 |
+
});
|
| 38 |
</script>
|
| 39 |
|
| 40 |
+
<Portal>
|
| 41 |
+
<div
|
| 42 |
+
role="presentation"
|
| 43 |
+
tabindex="-1"
|
| 44 |
+
bind:this={backdropEl}
|
| 45 |
+
on:click={handleBackdropClick}
|
| 46 |
+
transition:fade={{ easing: cubicOut, duration: 300 }}
|
| 47 |
+
class="fixed inset-0 z-40 flex items-center justify-center bg-black/80 p-8 backdrop-blur-sm dark:bg-black/50"
|
| 48 |
+
>
|
| 49 |
+
<div
|
| 50 |
+
role="dialog"
|
| 51 |
+
tabindex="-1"
|
| 52 |
+
bind:this={modalEl}
|
| 53 |
+
on:keydown={handleKeydown}
|
| 54 |
+
class="-mt-10 overflow-hidden rounded-2xl bg-white shadow-2xl outline-none md:-mt-20 {width}"
|
| 55 |
+
>
|
| 56 |
+
<slot />
|
| 57 |
+
</div>
|
| 58 |
</div>
|
| 59 |
+
</Portal>
|
src/lib/components/ModelsModal.svelte
CHANGED
|
@@ -18,7 +18,7 @@
|
|
| 18 |
const dispatch = createEventDispatcher<{ close: void }>();
|
| 19 |
</script>
|
| 20 |
|
| 21 |
-
<Modal width="max-w-lg">
|
| 22 |
<form
|
| 23 |
action="{base}/settings"
|
| 24 |
method="post"
|
|
|
|
| 18 |
const dispatch = createEventDispatcher<{ close: void }>();
|
| 19 |
</script>
|
| 20 |
|
| 21 |
+
<Modal width="max-w-lg" on:close>
|
| 22 |
<form
|
| 23 |
action="{base}/settings"
|
| 24 |
method="post"
|
src/lib/components/Portal.svelte
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script lang="ts">
|
| 2 |
+
import { onMount, onDestroy } from "svelte";
|
| 3 |
+
|
| 4 |
+
let el: HTMLElement;
|
| 5 |
+
|
| 6 |
+
onMount(() => {
|
| 7 |
+
el.ownerDocument.body.appendChild(el);
|
| 8 |
+
});
|
| 9 |
+
|
| 10 |
+
onDestroy(() => {
|
| 11 |
+
if (el?.parentNode) {
|
| 12 |
+
el.parentNode.removeChild(el);
|
| 13 |
+
}
|
| 14 |
+
});
|
| 15 |
+
</script>
|
| 16 |
+
|
| 17 |
+
<div bind:this={el} class="contents" hidden>
|
| 18 |
+
<slot />
|
| 19 |
+
</div>
|
src/lib/components/SettingsModal.svelte
CHANGED
|
@@ -13,7 +13,7 @@
|
|
| 13 |
const dispatch = createEventDispatcher<{ close: void }>();
|
| 14 |
</script>
|
| 15 |
|
| 16 |
-
<Modal>
|
| 17 |
<form
|
| 18 |
class="flex w-full flex-col gap-5 p-6"
|
| 19 |
use:enhance={() => {
|
|
@@ -24,7 +24,7 @@
|
|
| 24 |
>
|
| 25 |
<div class="flex items-start justify-between text-xl font-semibold text-gray-800">
|
| 26 |
<h2>Settings</h2>
|
| 27 |
-
<button class="group" on:click={() => dispatch("close")}>
|
| 28 |
<CarbonClose class="text-gray-900 group-hover:text-gray-500" />
|
| 29 |
</button>
|
| 30 |
</div>
|
|
|
|
| 13 |
const dispatch = createEventDispatcher<{ close: void }>();
|
| 14 |
</script>
|
| 15 |
|
| 16 |
+
<Modal on:close>
|
| 17 |
<form
|
| 18 |
class="flex w-full flex-col gap-5 p-6"
|
| 19 |
use:enhance={() => {
|
|
|
|
| 24 |
>
|
| 25 |
<div class="flex items-start justify-between text-xl font-semibold text-gray-800">
|
| 26 |
<h2>Settings</h2>
|
| 27 |
+
<button type="button" class="group" on:click={() => dispatch("close")}>
|
| 28 |
<CarbonClose class="text-gray-900 group-hover:text-gray-500" />
|
| 29 |
</button>
|
| 30 |
</div>
|