294 lines
7.1 KiB
Vue
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>
|