forked from
vt3e.cat/bbell
wip bsky client for the web & android
1<script setup lang="ts">
2import { computed, onMounted, onUnmounted, ref, nextTick } from 'vue'
3import { IconCloseRounded } from '@iconify-prerendered/vue-material-symbols'
4import { useEnvironmentStore } from '@/stores/environment'
5import { useOverlayInteractions } from '@/composables/useOverlayInteractions'
6
7defineProps<{
8 title?: string
9 width?: string
10 zIndex?: number
11 edgeToEdge?: boolean
12}>()
13
14const emit = defineEmits<{
15 (e: 'close'): void
16}>()
17
18const env = useEnvironmentStore()
19const isMobile = computed(() => env.isMobile)
20
21const modalContainerRef = ref<HTMLElement | null>(null)
22
23const { isDragging, currentY, backdropOpacity, onTouchStart, onTouchMove, onTouchEnd } =
24 useOverlayInteractions({
25 isMobile,
26 containerRef: modalContainerRef,
27 onClose: () => emit('close'),
28 focusFirstSelector: undefined,
29 allowDragFrom: (target) => !target.closest('.modal-body') && !target.closest('.modal-footer'),
30 closeThreshold: 150,
31 opacityDistance: 400,
32 })
33
34const getFocusableElements = (): HTMLElement[] => {
35 if (!modalContainerRef.value) return []
36 return Array.from(
37 modalContainerRef.value.querySelectorAll(
38 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
39 ),
40 ) as HTMLElement[]
41}
42
43const trapFocus = (e: KeyboardEvent) => {
44 if (!modalContainerRef.value) return
45 const focusableContent = getFocusableElements()
46 if (focusableContent.length === 0) return
47
48 const firstElement = focusableContent[0]
49 const lastElement = focusableContent[focusableContent.length - 1]
50
51 if (e.shiftKey) {
52 if (document.activeElement === firstElement) {
53 lastElement?.focus()
54 e.preventDefault()
55 }
56 } else {
57 if (document.activeElement === lastElement) {
58 firstElement?.focus()
59 e.preventDefault()
60 }
61 }
62}
63
64const handleKeydown = (e: KeyboardEvent) => {
65 if (e.key === 'Escape') {
66 emit('close')
67 }
68 if (e.key === 'Tab') {
69 trapFocus(e)
70 }
71}
72
73onMounted(async () => {
74 document.addEventListener('keydown', handleKeydown)
75 await nextTick()
76
77 if (modalContainerRef.value) {
78 const focusable = getFocusableElements()
79 if (focusable.length > 0) {
80 const firstContentFocus = focusable.find((el) => !el.classList.contains('close-btn'))
81 const elementToFocus = firstContentFocus || focusable[0]
82 elementToFocus?.focus()
83 } else {
84 modalContainerRef.value.focus()
85 }
86 }
87})
88
89onUnmounted(() => {
90 document.removeEventListener('keydown', handleKeydown)
91})
92</script>
93
94<template>
95 <div
96 class="modal-wrapper"
97 :style="{ zIndex: zIndex || 9999 }"
98 role="dialog"
99 aria-modal="true"
100 :aria-labelledby="title ? 'modal-title-id' : undefined"
101 >
102 <div
103 class="backdrop"
104 @click="emit('close')"
105 aria-hidden="true"
106 :style="{
107 opacity: isDragging ? backdropOpacity : undefined,
108 }"
109 :class="{ 'is-dragging': isDragging }"
110 ></div>
111
112 <div
113 ref="modalContainerRef"
114 class="modal-container"
115 :class="{ 'is-mobile': isMobile, 'is-desktop': !isMobile, 'edge-to-edge': edgeToEdge }"
116 role="dialog"
117 aria-modal="true"
118 :aria-labelledby="title ? 'modal-title-id' : undefined"
119 tabindex="-1"
120 :style="{ zIndex: (zIndex || 9999) + 1 }"
121 @click.self="emit('close')"
122 >
123 <div
124 class="modal-content"
125 :style="{
126 maxWidth: width || '768px',
127 transform: isMobile && currentY > 0 ? `translateY(${currentY}px)` : undefined,
128 transition: isDragging ? 'none' : undefined,
129 }"
130 @click.stop
131 @touchstart="onTouchStart"
132 @touchmove="onTouchMove"
133 @touchend="onTouchEnd"
134 >
135 <div v-if="isMobile" class="drag-handle-wrapper">
136 <div class="drag-handle" aria-hidden="true"></div>
137 </div>
138
139 <div class="modal-header">
140 <h2 v-if="title" id="modal-title-id" class="modal-title">{{ title }}</h2>
141
142 <button class="close-btn" @click="emit('close')" aria-label="Close modal" type="button">
143 <IconCloseRounded />
144 </button>
145 </div>
146
147 <div class="modal-body">
148 <slot />
149 </div>
150
151 <div v-if="$slots.footer" class="modal-footer">
152 <slot name="footer" />
153 </div>
154 </div>
155 </div>
156 </div>
157</template>
158
159<style scoped lang="scss">
160@use '@/assets/variables' as *;
161
162.modal-wrapper,
163.backdrop,
164.modal-container,
165.modal-content {
166 transition-timing-function: $ease-spring;
167 transition-duration: 0.3s;
168}
169
170.modal-wrapper {
171 position: fixed;
172 inset: 0;
173 display: flex;
174 flex-direction: column;
175 pointer-events: none;
176}
177
178.backdrop {
179 position: absolute;
180 inset: 0;
181 background: hsla(var(--crust) / 0.6);
182 backdrop-filter: blur(4px);
183 pointer-events: auto;
184 &.is-dragging {
185 transition: none;
186 }
187}
188
189.modal-container {
190 position: absolute;
191 inset: 0;
192 display: flex;
193 flex-direction: column;
194 outline-color: transparent;
195 pointer-events: none;
196
197 &.edge-to-edge {
198 .modal-content {
199 width: 100%;
200 max-width: none;
201 }
202
203 .modal-body {
204 padding: 0;
205 }
206 }
207}
208
209.modal-content {
210 pointer-events: auto;
211 background: hsl(var(--base));
212 display: flex;
213 flex-direction: column;
214 max-height: 90vh;
215 width: 100%;
216 position: relative;
217 box-shadow:
218 0 10px 25px -5px rgba(0, 0, 0, 0.1),
219 0 8px 10px -6px rgba(0, 0, 0, 0.1);
220 will-change: transform;
221}
222
223.is-desktop {
224 inset: 0;
225 align-items: center;
226 justify-content: center;
227 padding: 1rem;
228 pointer-events: auto;
229
230 .modal-header {
231 padding-top: 1.25rem;
232 }
233
234 .modal-content {
235 border-radius: 1rem;
236 border: 1px solid hsla(var(--surface2) / 0.2);
237 }
238}
239
240.is-mobile {
241 bottom: 0;
242 left: 0;
243 right: 0;
244 justify-content: flex-end;
245
246 .modal-content {
247 border-top-left-radius: 1.5rem;
248 border-top-right-radius: 1.5rem;
249 padding-bottom: env(safe-area-inset-bottom, 20px);
250 max-height: 85vh;
251 }
252}
253
254.modal-header {
255 display: flex;
256 align-items: center;
257 justify-content: space-between;
258 padding: 1rem;
259 flex-shrink: 0;
260 border-bottom: 1px solid hsl(var(--surface0));
261
262 .modal-title {
263 font-size: 1.25rem;
264 font-weight: 700;
265 color: hsl(var(--text));
266 margin: 0;
267 }
268}
269
270.drag-handle-wrapper {
271 width: 100%;
272 display: flex;
273 justify-content: center;
274 padding-top: 0.75rem;
275 padding-bottom: 0.25rem;
276 touch-action: none;
277
278 .drag-handle {
279 width: 3rem;
280 height: 0.25rem;
281 background: hsl(var(--surface2));
282 border-radius: 99px;
283 }
284}
285
286.close-btn {
287 background: transparent;
288 border: none;
289 font-size: 1.5rem;
290 color: hsl(var(--subtext0));
291 cursor: pointer;
292
293 width: 2rem;
294 height: 2rem;
295
296 display: flex;
297 align-items: center;
298 justify-content: center;
299
300 padding: 0;
301 line-height: 1;
302
303 margin-left: auto;
304 border-radius: 0.25rem;
305
306 &:focus-visible,
307 &:hover {
308 color: hsl(var(--text));
309 background: hsla(var(--surface0) / 0.5);
310 }
311}
312
313.modal-body {
314 padding: 1rem;
315 overflow-y: auto;
316 flex: 1;
317 color: hsl(var(--text));
318}
319
320.modal-footer {
321 padding: 1rem 1.5rem;
322 border-top: 1px solid hsl(var(--surface0));
323 display: flex;
324 gap: 0.5rem;
325 justify-content: flex-end;
326}
327
328.fade-enter-active,
329.fade-leave-active {
330 transition: opacity 0.2s ease;
331}
332.fade-enter-from,
333.fade-leave-to {
334 opacity: 0;
335}
336
337.zoom-enter-active,
338.zoom-leave-active {
339 transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
340}
341.zoom-enter-from,
342.zoom-leave-to {
343 opacity: 0;
344 transform: scale(0.95);
345}
346
347.slide-up-enter-active,
348.slide-up-leave-active {
349 transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1);
350}
351.slide-up-enter-from,
352.slide-up-leave-to {
353 transform: translateY(100%);
354}
355</style>