web based infinite canvas
1<script lang="ts">
2 import type { Snippet } from 'svelte';
3
4 /** Which side the sheet slides in from */
5 type Side = 'left' | 'right' | 'top' | 'bottom';
6
7 type Props = {
8 open: boolean;
9 onClose?: () => void;
10 title?: string;
11 side?: Side;
12 closeOnBackdrop?: boolean;
13 closeOnEscape?: boolean;
14 class?: string;
15 backdropClass?: string;
16 children?: Snippet;
17 };
18
19 let {
20 open = $bindable(false),
21 onClose,
22 title,
23 children,
24 side = 'right',
25 closeOnBackdrop = true,
26 closeOnEscape = true,
27 class: className = '',
28 backdropClass = ''
29 }: Props = $props();
30
31 let sheetElement = $state<HTMLDivElement>();
32
33 function handleBackdropClick(event: MouseEvent) {
34 if (closeOnBackdrop && event.target === event.currentTarget) {
35 handleClose();
36 }
37 }
38
39 function handleKeyDown(event: KeyboardEvent) {
40 if (closeOnEscape && event.key === 'Escape') {
41 event.preventDefault();
42 handleClose();
43 }
44 }
45
46 function handleClose() {
47 open = false;
48 onClose?.();
49 }
50
51 $effect(() => {
52 if (open && sheetElement) {
53 sheetElement.focus();
54
55 const previouslyFocused = document.activeElement as HTMLElement;
56
57 return () => {
58 previouslyFocused?.focus();
59 };
60 }
61 });
62</script>
63
64{#if open}
65 <div
66 class="sheet__backdrop {backdropClass}"
67 role="presentation"
68 onclick={handleBackdropClick}
69 onkeydown={handleKeyDown}>
70 <div
71 bind:this={sheetElement}
72 class="sheet sheet__content sheet__content--{side} sheet-{side} {className}"
73 role="dialog"
74 aria-modal="true"
75 aria-label={title}
76 tabindex="-1">
77 {@render children?.()}
78 </div>
79 </div>
80{/if}
81
82<style>
83 .sheet__backdrop {
84 position: fixed;
85 top: 0;
86 left: 0;
87 width: 100vw;
88 height: 100vh;
89 background-color: rgba(0, 0, 0, 0.5);
90 display: flex;
91 z-index: 1000;
92 animation: fadeIn 0.15s ease-out;
93 }
94
95 .sheet__content {
96 background-color: var(--surface);
97 color: var(--text);
98 box-shadow:
99 0 10px 25px rgba(0, 0, 0, 0.1),
100 0 4px 10px rgba(0, 0, 0, 0.08);
101 overflow: auto;
102 outline: none;
103 }
104
105 /* Right side (default) */
106 .sheet__content--right {
107 position: fixed;
108 top: 0;
109 right: 0;
110 height: 100vh;
111 width: min(400px, 80vw);
112 animation: slideInRight 0.2s ease-out;
113 }
114
115 /* Left side */
116 .sheet__content--left {
117 position: fixed;
118 top: 0;
119 left: 0;
120 height: 100vh;
121 width: min(400px, 80vw);
122 animation: slideInLeft 0.2s ease-out;
123 }
124
125 /* Top side */
126 .sheet__content--top {
127 position: fixed;
128 top: 0;
129 left: 0;
130 width: 100vw;
131 height: min(400px, 80vh);
132 animation: slideInTop 0.2s ease-out;
133 }
134
135 /* Bottom side */
136 .sheet__content--bottom {
137 position: fixed;
138 bottom: 0;
139 left: 0;
140 width: 100vw;
141 height: min(400px, 80vh);
142 animation: slideInBottom 0.2s ease-out;
143 }
144
145 @keyframes fadeIn {
146 from {
147 opacity: 0;
148 }
149 to {
150 opacity: 1;
151 }
152 }
153
154 @keyframes slideInRight {
155 from {
156 transform: translateX(100%);
157 }
158 to {
159 transform: translateX(0);
160 }
161 }
162
163 @keyframes slideInLeft {
164 from {
165 transform: translateX(-100%);
166 }
167 to {
168 transform: translateX(0);
169 }
170 }
171
172 @keyframes slideInTop {
173 from {
174 transform: translateY(-100%);
175 }
176 to {
177 transform: translateY(0);
178 }
179 }
180
181 @keyframes slideInBottom {
182 from {
183 transform: translateY(100%);
184 }
185 to {
186 transform: translateY(0);
187 }
188 }
189</style>