Files
ghatreh_user/src/components/SupportMessengerPicker.vue
T
2026-07-16 11:14:58 +03:30

294 lines
7.1 KiB
Vue

<template>
<Teleport to="body">
<Transition name="support-picker-backdrop">
<div
v-if="open"
class="support-messenger-picker__backdrop"
:class="{ 'is-active': backdropActive }"
@click="onBackdropClick"
/>
</Transition>
<div
v-show="open || closing"
class="support-messenger-picker__stack"
:class="[
{ 'is-open': open },
expandUp ? 'support-messenger-picker__stack--up' : 'support-messenger-picker__stack--down',
]"
:style="stackStyle"
role="menu"
aria-label="انتخاب پیام‌رسان"
>
<button
v-for="(entry, index) in entries"
:key="`${entry.messenger}-${entry.id}`"
type="button"
class="support-messenger-picker__btn"
:style="{ '--i': index, '--total': entries.length }"
:title="entry.label"
:aria-label="entry.label"
@click.stop="select(entry)"
>
<SupportMessengerBrandIcon :messenger="entry.messenger" />
</button>
</div>
</Teleport>
</template>
<script lang="ts" setup>
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
import SupportMessengerBrandIcon from '@/components/SupportMessengerBrandIcon.vue'
import type { ResolvedSupportMessengerEntry } from '@/utils/supportMessenger'
const props = withDefaults(
defineProps<{
open: boolean
entries: ResolvedSupportMessengerEntry[]
anchorEl?: HTMLElement | null
}>(),
{
anchorEl: null,
},
)
const emit = defineEmits<{
'update:open': [value: boolean]
select: [entry: ResolvedSupportMessengerEntry]
}>()
const BTN_SIZE = 46
const GAP = 12
const STACK_GAP = 10
const CLOSE_MS = 220
const BACKDROP_GUARD_MS = 380
const stackStyle = ref<Record<string, string>>({})
const closing = ref(false)
const expandUp = ref(true)
const backdropActive = ref(false)
let closeTimer: ReturnType<typeof setTimeout> | null = null
let backdropTimer: ReturnType<typeof setTimeout> | null = null
function close() {
emit('update:open', false)
}
function select(entry: ResolvedSupportMessengerEntry) {
emit('select', entry)
close()
}
function clearTimers() {
if (closeTimer) {
clearTimeout(closeTimer)
closeTimer = null
}
if (backdropTimer) {
clearTimeout(backdropTimer)
backdropTimer = null
}
}
function onBackdropClick() {
if (!backdropActive.value) return
close()
}
function armBackdropGuard() {
backdropActive.value = false
if (backdropTimer) clearTimeout(backdropTimer)
backdropTimer = setTimeout(() => {
backdropActive.value = true
backdropTimer = null
}, BACKDROP_GUARD_MS)
}
async function reposition() {
await nextTick()
const anchor = props.anchorEl
const count = props.entries.length
if (!anchor || count === 0) {
expandUp.value = true
stackStyle.value = {
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
right: 'auto',
bottom: 'auto',
'--stack-rise': '56px',
}
return
}
const rect = anchor.getBoundingClientRect()
const stackHeight = count * BTN_SIZE + Math.max(0, count - 1) * GAP
const anchorCenterX = rect.left + rect.width / 2
const anchorCenterY = rect.top + rect.height / 2
const right = Math.max(12, window.innerWidth - anchorCenterX - BTN_SIZE / 2)
expandUp.value = anchorCenterY > window.innerHeight / 2
if (expandUp.value) {
const bottom = Math.max(12, window.innerHeight - rect.top + STACK_GAP)
stackStyle.value = {
right: `${right}px`,
bottom: `${bottom}px`,
left: 'auto',
top: 'auto',
transform: 'none',
'--stack-rise': `${Math.min(stackHeight + 6, 220)}px`,
}
} else {
const top = Math.max(12, rect.bottom + STACK_GAP)
stackStyle.value = {
right: `${right}px`,
top: `${top}px`,
left: 'auto',
bottom: 'auto',
transform: 'none',
'--stack-rise': `${Math.min(stackHeight + 6, 220)}px`,
}
}
}
watch(
() => props.open,
(isOpen) => {
clearTimers()
if (isOpen) {
closing.value = false
armBackdropGuard()
void reposition()
return
}
backdropActive.value = false
closing.value = true
const delay = CLOSE_MS + Math.max(0, props.entries.length - 1) * 28
closeTimer = setTimeout(() => {
closing.value = false
closeTimer = null
}, delay)
},
{ flush: 'post' },
)
watch(
() => [props.entries.length, props.anchorEl] as const,
() => {
if (props.open) void reposition()
},
)
function onViewportChange() {
if (props.open || closing.value) void reposition()
}
if (typeof window !== 'undefined') {
window.addEventListener('resize', onViewportChange, { passive: true })
window.addEventListener('scroll', onViewportChange, { passive: true, capture: true })
}
onBeforeUnmount(() => {
clearTimers()
if (typeof window === 'undefined') return
window.removeEventListener('resize', onViewportChange, { capture: true })
window.removeEventListener('scroll', onViewportChange, { capture: true })
})
</script>
<style scoped lang="less">
.support-messenger-picker__backdrop {
position: fixed;
inset: 0;
z-index: 5195;
background: rgba(15, 23, 42, 0.2);
pointer-events: none;
&.is-active {
pointer-events: auto;
}
}
.support-messenger-picker__stack {
position: fixed;
z-index: 5205;
display: flex;
align-items: center;
gap: 12px;
pointer-events: none;
}
.support-messenger-picker__stack--up {
flex-direction: column-reverse;
}
.support-messenger-picker__stack--down {
flex-direction: column;
}
.support-messenger-picker__btn {
--i: 0;
--total: 1;
--stack-rise: 56px;
pointer-events: auto;
width: 46px;
height: 46px;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
cursor: pointer;
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.2);
opacity: 0;
will-change: transform, opacity;
transition:
transform 0.22s cubic-bezier(0.2, 1, 0.3, 1),
opacity 0.16s ease,
box-shadow 0.16s ease;
transition-delay: calc(var(--i) * 38ms);
}
.support-messenger-picker__stack--up .support-messenger-picker__btn {
transform-origin: center bottom;
transform: translateY(calc(var(--stack-rise) * 0.55)) scale(0.2);
}
.support-messenger-picker__stack--down .support-messenger-picker__btn {
transform-origin: center top;
transform: translateY(calc(var(--stack-rise) * -0.55)) scale(0.2);
}
.support-messenger-picker__stack.is-open .support-messenger-picker__btn {
opacity: 1;
transform: translateY(0) scale(1);
}
.support-messenger-picker__stack:not(.is-open) .support-messenger-picker__btn {
transition-delay: calc((var(--total) - 1 - var(--i)) * 24ms);
transition-duration: 0.18s, 0.14s, 0.14s;
opacity: 0;
pointer-events: none;
}
.support-messenger-picker__stack--up:not(.is-open) .support-messenger-picker__btn {
transform: translateY(calc(var(--stack-rise) * 0.4)) scale(0.15);
}
.support-messenger-picker__stack--down:not(.is-open) .support-messenger-picker__btn {
transform: translateY(calc(var(--stack-rise) * -0.4)) scale(0.15);
}
.support-picker-backdrop-enter-active,
.support-picker-backdrop-leave-active {
transition: opacity 0.16s ease;
}
.support-picker-backdrop-enter-from,
.support-picker-backdrop-leave-to {
opacity: 0;
}
</style>