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