wip bsky client for the web & android bbell.vt3e.cat
at main 342 lines 7.0 kB view raw
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>