wip bsky client for the web & android bbell.vt3e.cat

refactor(modal,popover): extract overlay behaviours into composables

vt3e.cat b9e932ef 55c4add4

verified
+254 -133
+11 -45
src/components/UI/BaseModal.vue
··· 2 2 import { computed, onMounted, onUnmounted, ref, nextTick } from 'vue' 3 3 import { IconCloseRounded } from '@iconify-prerendered/vue-material-symbols' 4 4 import { useEnvironmentStore } from '@/stores/environment' 5 + import { useOverlayInteractions } from '@/composables/useOverlayInteractions' 5 6 6 7 defineProps<{ 7 8 title?: string ··· 18 19 19 20 const modalContainerRef = ref<HTMLElement | null>(null) 20 21 21 - const isDragging = ref(false) 22 - const startY = ref(0) 23 - const currentY = ref(0) 24 - const backdropOpacity = ref(1) 22 + const { 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 + }) 25 32 26 33 const getFocusableElements = (): HTMLElement[] => { 27 34 if (!modalContainerRef.value) return [] ··· 59 66 } 60 67 if (e.key === 'Tab') { 61 68 trapFocus(e) 62 - } 63 - } 64 - 65 - // Drag Event Handlers 66 - const onTouchStart = (e: TouchEvent) => { 67 - if (!isMobile.value) return 68 - // Only allow dragging from the header area or handle 69 - const target = e.target as HTMLElement 70 - if (target.closest('.modal-body') || target.closest('.modal-footer')) return 71 - if (!e.touches[0]) return 72 - 73 - isDragging.value = true 74 - startY.value = e.touches[0].clientY 75 - currentY.value = 0 76 - } 77 - 78 - const onTouchMove = (e: TouchEvent) => { 79 - if (!isDragging.value) return 80 - if (!e.touches[0]) return 81 - 82 - const deltaY = e.touches[0].clientY - startY.value 83 - if (deltaY > 0) { 84 - e.preventDefault() // Prevent scrolling while dragging down 85 - currentY.value = deltaY 86 - 87 - // Calculate opacity based on drag percentage (assuming ~500px height as threshold) 88 - const percentage = Math.max(0, 1 - deltaY / 400) 89 - backdropOpacity.value = percentage 90 - } 91 - } 92 - 93 - const onTouchEnd = () => { 94 - if (!isDragging.value) return 95 - isDragging.value = false 96 - 97 - if (currentY.value > 150) { 98 - emit('close') 99 - } else { 100 - // Reset 101 - currentY.value = 0 102 - backdropOpacity.value = 1 103 69 } 104 70 } 105 71
+52 -88
src/components/UI/BasePopover.vue
··· 1 1 <script setup lang="ts"> 2 - import { computed, onMounted, onUnmounted, ref, nextTick, watch } from 'vue' 2 + import { computed, nextTick, ref, watch, onMounted, onUnmounted } from 'vue' 3 3 import { useEnvironmentStore } from '@/stores/environment' 4 + import { useOverlayInteractions } from '@/composables/useOverlayInteractions' 5 + import { useClickOutside } from '@/composables/useClickOutside' 6 + import { useReposition } from '@/composables/useReposition' 4 7 5 8 import type { Component } from 'vue' 6 9 ··· 40 43 const isOpen = ref(false) 41 44 const triggerRef = ref<HTMLElement | null>(null) 42 45 const popoverRef = ref<HTMLElement | null>(null) 43 - const popoverPos = ref({ top: 0, left: 0 }) 44 46 45 47 const isGroup = (item: unknown): item is PopoverActionGroup => 46 48 item !== null && ··· 60 62 ] 61 63 }) 62 64 63 - const isDragging = ref(false) 64 - const startY = ref(0) 65 - const currentY = ref(0) 66 - const backdropOpacity = ref(1) 65 + const { isDragging, currentY, backdropOpacity, onTouchStart, onTouchMove, onTouchEnd } = 66 + useOverlayInteractions({ 67 + isMobile, 68 + containerRef: popoverRef, 69 + onClose: () => close(), 70 + // disallow dragging when starting from the action list 71 + allowDragFrom: (target: HTMLElement) => !target.closest('.popover-action-list'), 72 + closeThreshold: 120, 73 + opacityDistance: 300, 74 + }) 67 75 68 - const onTouchStart = (e: TouchEvent) => { 69 - const target = e.target as HTMLElement 70 - if (target.closest('.popover-action-list')) return 71 - if (!e.touches[0]) return 76 + useClickOutside({ 77 + isOpen, 78 + containerRef: popoverRef, 79 + triggerRef, 80 + isMobile, 81 + onClose: () => close(), 82 + }) 72 83 73 - isDragging.value = true 74 - startY.value = e.touches[0].clientY 75 - currentY.value = 0 76 - } 84 + const { popoverPos, calculatePosition } = useReposition({ 85 + triggerRef, 86 + containerRef: popoverRef, 87 + isOpen, 88 + isMobile, 89 + width: computed(() => props.width), 90 + align: computed(() => props.align), 91 + }) 77 92 78 - const onTouchMove = (e: TouchEvent) => { 79 - if (!isDragging.value || !e.touches[0]) return 80 - const deltaY = e.touches[0].clientY - startY.value 81 - if (deltaY > 0) { 82 - e.preventDefault() 83 - currentY.value = deltaY 84 - backdropOpacity.value = Math.max(0, 1 - deltaY / 300) 93 + const handleKeydown = (e: KeyboardEvent) => { 94 + if (!isOpen.value) return 95 + 96 + if (e.key === 'Escape') { 97 + close() 98 + return 85 99 } 86 - } 87 100 88 - const onTouchEnd = () => { 89 - if (!isDragging.value) return 90 - isDragging.value = false 91 - 92 - if (currentY.value > 120) { 101 + if (e.key === 'Tab') { 93 102 close() 94 - } else { 95 - currentY.value = 0 96 - backdropOpacity.value = 1 103 + return 97 104 } 98 - } 99 105 100 - const calculatePosition = () => { 101 - if (!triggerRef.value) return 102 - const rect = triggerRef.value.getBoundingClientRect() 103 - const gap = 8 106 + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { 107 + e.preventDefault() 108 + const items = Array.from( 109 + popoverRef.value?.querySelectorAll<HTMLElement>('[role="menuitem"]:not([disabled])') ?? [], 110 + ) 111 + if (items.length === 0) return 104 112 105 - popoverPos.value = { 106 - top: rect.bottom + gap, 107 - left: props.align === 'right' ? rect.right - parseInt(props.width) : rect.left, 113 + const current = document.activeElement as HTMLElement 114 + const idx = items.indexOf(current) 115 + 116 + if (e.key === 'ArrowDown') { 117 + items[(idx + 1) % items.length]?.focus() 118 + } else { 119 + items[(idx - 1 + items.length) % items.length]?.focus() 120 + } 108 121 } 109 122 } 110 123 111 124 const open = async () => { 112 125 isOpen.value = true 113 126 emit('open') 127 + await nextTick() 114 128 115 129 if (!isMobile.value) { 116 - await nextTick() 117 130 calculatePosition() 118 - 119 131 const first = popoverRef.value?.querySelector<HTMLElement>('[role="menuitem"]') 120 132 first?.focus() 121 133 } else { ··· 138 150 139 151 const toggle = () => (isOpen.value ? close() : open()) 140 152 141 - const handleKeydown = (e: KeyboardEvent) => { 142 - if (!isOpen.value) return 143 - 144 - if (e.key === 'Escape') { 145 - close() 146 - return 147 - } 148 - 149 - if (e.key === 'Tab') { 150 - close() 151 - return 152 - } 153 - 154 - if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { 155 - e.preventDefault() 156 - const items = Array.from( 157 - popoverRef.value?.querySelectorAll<HTMLElement>('[role="menuitem"]:not([disabled])') ?? [], 158 - ) 159 - if (items.length === 0) return 160 - 161 - const current = document.activeElement as HTMLElement 162 - const idx = items.indexOf(current) 163 - 164 - if (e.key === 'ArrowDown') { 165 - items[(idx + 1) % items.length]?.focus() 166 - } else { 167 - items[(idx - 1 + items.length) % items.length]?.focus() 168 - } 169 - } 170 - } 171 - 172 - const handleClickOutside = (e: MouseEvent) => { 173 - if (!isOpen.value || isMobile.value) return 174 - const target = e.target as Node 175 - if (!popoverRef.value?.contains(target) && !triggerRef.value?.contains(target)) { 176 - close() 177 - } 178 - } 179 - 180 - const handleReposition = () => { 181 - if (isOpen.value && !isMobile.value) calculatePosition() 182 - } 183 153 watch(isMobile, () => { 184 154 if (isOpen.value) close() 185 155 }) 186 156 187 157 onMounted(() => { 188 158 document.addEventListener('keydown', handleKeydown) 189 - document.addEventListener('mousedown', handleClickOutside) 190 - window.addEventListener('resize', handleReposition) 191 - window.addEventListener('scroll', handleReposition, true) 192 159 }) 193 160 194 161 onUnmounted(() => { 195 162 document.removeEventListener('keydown', handleKeydown) 196 - document.removeEventListener('mousedown', handleClickOutside) 197 - window.removeEventListener('resize', handleReposition) 198 - window.removeEventListener('scroll', handleReposition, true) 199 163 }) 200 164 201 165 defineExpose({ open, close, toggle })
+27
src/composables/useClickOutside.ts
··· 1 + import { onMounted, onUnmounted, type Ref } from 'vue' 2 + 3 + export function useClickOutside(options: { 4 + isOpen: Ref<boolean> 5 + containerRef: Ref<HTMLElement | null> 6 + triggerRef?: Ref<HTMLElement | null> 7 + isMobile?: Ref<boolean> 8 + onClose: () => void 9 + }) { 10 + const { isOpen, containerRef, triggerRef, isMobile, onClose } = options 11 + 12 + const handleClickOutside = (e: MouseEvent) => { 13 + if (!isOpen.value) return 14 + if (isMobile?.value) return 15 + const target = e.target as Node 16 + if (!containerRef.value?.contains(target) && !triggerRef?.value?.contains(target)) { 17 + onClose() 18 + } 19 + } 20 + 21 + onMounted(() => { 22 + document.addEventListener('mousedown', handleClickOutside) 23 + }) 24 + onUnmounted(() => { 25 + document.removeEventListener('mousedown', handleClickOutside) 26 + }) 27 + }
+124
src/composables/useOverlayInteractions.ts
··· 1 + import { ref, onMounted, onUnmounted, nextTick, type Ref } from 'vue' 2 + 3 + type Options = { 4 + isMobile: Ref<boolean> 5 + containerRef: Ref<HTMLElement | null> 6 + onClose: () => void 7 + /** selector for the first element to focus (e.g. '[role="menuitem"]') */ 8 + focusFirstSelector?: string 9 + /** decide whether a touchstart target is allowed to start a drag*/ 10 + allowDragFrom?: (target: HTMLElement) => boolean 11 + closeThreshold?: number 12 + opacityDistance?: number 13 + } 14 + 15 + export function useOverlayInteractions(opts: Options) { 16 + const { isMobile, containerRef, onClose } = opts 17 + const isDragging = ref(false) 18 + const startY = ref(0) 19 + const currentY = ref(0) 20 + const backdropOpacity = ref(1) 21 + 22 + const getFocusableElements = (): HTMLElement[] => { 23 + if (!containerRef.value) return [] 24 + return Array.from( 25 + containerRef.value.querySelectorAll<HTMLElement>( 26 + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', 27 + ), 28 + ) 29 + } 30 + 31 + const trapFocus = (e: KeyboardEvent) => { 32 + if (!containerRef.value) return 33 + const focusable = getFocusableElements() 34 + if (!focusable.length) return 35 + 36 + const first = focusable[0] 37 + const last = focusable[focusable.length - 1] 38 + 39 + if (e.shiftKey) { 40 + if (document.activeElement === first) { 41 + last?.focus() 42 + e.preventDefault() 43 + } 44 + } else { 45 + if (document.activeElement === last) { 46 + first?.focus() 47 + e.preventDefault() 48 + } 49 + } 50 + } 51 + 52 + const handleKeydown = (e: KeyboardEvent) => { 53 + if (e.key === 'Escape') { 54 + onClose() 55 + } else if (e.key === 'Tab') { 56 + trapFocus(e) 57 + } 58 + } 59 + 60 + const onTouchStart = (e: TouchEvent) => { 61 + if (!isMobile.value) return 62 + if (!e.touches[0]) return 63 + const target = e.target as HTMLElement 64 + if (opts.allowDragFrom && !opts.allowDragFrom(target)) return 65 + 66 + isDragging.value = true 67 + startY.value = e.touches[0].clientY 68 + currentY.value = 0 69 + } 70 + 71 + const onTouchMove = (e: TouchEvent) => { 72 + if (!isDragging.value) return 73 + if (!e.touches[0]) return 74 + 75 + const deltaY = e.touches[0].clientY - startY.value 76 + if (deltaY > 0) { 77 + e.preventDefault() 78 + currentY.value = deltaY 79 + const distance = opts.opacityDistance ?? 400 80 + backdropOpacity.value = Math.max(0, 1 - deltaY / distance) 81 + } 82 + } 83 + 84 + const onTouchEnd = () => { 85 + if (!isDragging.value) return 86 + isDragging.value = false 87 + const threshold = opts.closeThreshold ?? 150 88 + if (currentY.value > threshold) { 89 + onClose() 90 + } else { 91 + currentY.value = 0 92 + backdropOpacity.value = 1 93 + } 94 + } 95 + 96 + onMounted(async () => { 97 + document.addEventListener('keydown', handleKeydown) 98 + await nextTick() 99 + if (containerRef.value) { 100 + const focusable = getFocusableElements() 101 + if (opts.focusFirstSelector) { 102 + const first = containerRef.value.querySelector<HTMLElement>(opts.focusFirstSelector) 103 + first?.focus() 104 + } else if (focusable.length) { 105 + focusable[0]?.focus() 106 + } else { 107 + containerRef.value.focus() 108 + } 109 + } 110 + }) 111 + 112 + onUnmounted(() => { 113 + document.removeEventListener('keydown', handleKeydown) 114 + }) 115 + 116 + return { 117 + isDragging, 118 + currentY, 119 + backdropOpacity, 120 + onTouchStart, 121 + onTouchMove, 122 + onTouchEnd, 123 + } 124 + }
+40
src/composables/useReposition.ts
··· 1 + import { ref, onMounted, onUnmounted, type Ref } from 'vue' 2 + 3 + export function useReposition(options: { 4 + triggerRef: Ref<HTMLElement | null> 5 + containerRef: Ref<HTMLElement | null> 6 + isOpen: Ref<boolean> 7 + isMobile: Ref<boolean> 8 + width: Ref<string> 9 + align: Ref<'left' | 'right'> 10 + }) { 11 + const popoverPos = ref({ top: 0, left: 0 }) 12 + 13 + const calculatePosition = () => { 14 + const trigger = options.triggerRef.value 15 + if (!trigger) return 16 + const rect = trigger.getBoundingClientRect() 17 + const gap = 8 18 + const parsedW = parseInt(options.width.value || '200') 19 + popoverPos.value = { 20 + top: rect.bottom + gap, 21 + left: options.align.value === 'right' ? rect.right - parsedW : rect.left, 22 + } 23 + } 24 + 25 + const handleReposition = () => { 26 + if (options.isOpen.value && !options.isMobile.value) calculatePosition() 27 + } 28 + 29 + onMounted(() => { 30 + window.addEventListener('resize', handleReposition) 31 + window.addEventListener('scroll', handleReposition, true) 32 + }) 33 + 34 + onUnmounted(() => { 35 + window.removeEventListener('resize', handleReposition) 36 + window.removeEventListener('scroll', handleReposition, true) 37 + }) 38 + 39 + return { popoverPos, calculatePosition } 40 + }