wip bsky client for the web & android
at main 355 lines 7.2 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 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>