194 lines
5.1 KiB
Vue
194 lines
5.1 KiB
Vue
<script setup>
|
|
const openMenuId = defineModel('openMenuId', {
|
|
type: String,
|
|
default: ''
|
|
})
|
|
|
|
const props = defineProps({
|
|
/** 이 메뉴 인스턴스의 고유 id */
|
|
itemId: {
|
|
type: String,
|
|
required: true
|
|
},
|
|
/** 트리거 비활성화 */
|
|
disabled: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
/** 처리 중(… 표시) */
|
|
busy: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
/** 접근성 라벨 접두사 */
|
|
menuLabel: {
|
|
type: String,
|
|
default: '메뉴'
|
|
},
|
|
/** 트리거 크기: md(테이블) | sm(배지·사이드) */
|
|
size: {
|
|
type: String,
|
|
default: 'md'
|
|
},
|
|
/** 어두운 배경 위 트리거(미디어 폴더 선택 행 등) */
|
|
inverse: {
|
|
type: Boolean,
|
|
default: false
|
|
}
|
|
})
|
|
|
|
const triggerRef = ref(null)
|
|
const popoverStyle = ref({})
|
|
|
|
/** @type {import('vue').ComputedRef<boolean>} */
|
|
const isOpen = computed(() => openMenuId.value === props.itemId)
|
|
|
|
/** @type {import('vue').ComputedRef<string>} */
|
|
const triggerSizeClass = computed(() => (props.size === 'sm' ? 'size-7' : 'size-9'))
|
|
|
|
/** @type {import('vue').ComputedRef<string>} */
|
|
const iconSizeClass = computed(() => (props.size === 'sm' ? 'size-5' : 'size-6'))
|
|
|
|
/** @type {import('vue').ComputedRef<string>} */
|
|
const triggerToneClass = computed(() => (
|
|
props.inverse
|
|
? 'text-white hover:bg-white/15 focus-visible:ring-white/40'
|
|
: 'text-[#394047] hover:bg-[#eceff2]'
|
|
))
|
|
|
|
/**
|
|
* 행 메뉴 위치를 화면 기준으로 계산한다.
|
|
* @returns {void}
|
|
*/
|
|
const updatePopoverPosition = () => {
|
|
if (!import.meta.client || !triggerRef.value) {
|
|
return
|
|
}
|
|
|
|
const rect = triggerRef.value.getBoundingClientRect()
|
|
const menuWidth = props.size === 'sm' ? 176 : 176
|
|
const estimatedHeight = 112
|
|
const margin = 8
|
|
const left = Math.max(margin, Math.min(rect.right - menuWidth, window.innerWidth - menuWidth - margin))
|
|
const opensUp = rect.bottom + estimatedHeight + margin > window.innerHeight
|
|
const top = opensUp
|
|
? Math.max(margin, rect.top - estimatedHeight - 4)
|
|
: rect.bottom + 4
|
|
|
|
popoverStyle.value = {
|
|
left: `${left}px`,
|
|
top: `${top}px`,
|
|
width: `${menuWidth}px`
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 메뉴 열기/닫기
|
|
* @returns {void}
|
|
*/
|
|
const toggleMenu = () => {
|
|
if (props.disabled) {
|
|
return
|
|
}
|
|
|
|
openMenuId.value = isOpen.value ? '' : props.itemId
|
|
}
|
|
|
|
watch(isOpen, async (open) => {
|
|
if (!open) {
|
|
return
|
|
}
|
|
|
|
await nextTick()
|
|
updatePopoverPosition()
|
|
})
|
|
|
|
onMounted(() => {
|
|
window.addEventListener('resize', updatePopoverPosition)
|
|
window.addEventListener('scroll', updatePopoverPosition, true)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
window.removeEventListener('resize', updatePopoverPosition)
|
|
window.removeEventListener('scroll', updatePopoverPosition, true)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
class="admin-row-more-menu relative inline-flex justify-end"
|
|
data-admin-row-menu
|
|
@mousedown.stop
|
|
>
|
|
<button
|
|
ref="triggerRef"
|
|
class="admin-row-more-menu__trigger inline-flex items-center justify-center rounded transition-colors focus-visible:outline focus-visible:ring-2 focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-40"
|
|
:class="[triggerSizeClass, triggerToneClass]"
|
|
type="button"
|
|
:disabled="disabled"
|
|
:aria-expanded="isOpen"
|
|
aria-haspopup="menu"
|
|
:aria-label="isOpen ? `${menuLabel} 닫기` : menuLabel"
|
|
@mousedown.stop
|
|
@click.stop="toggleMenu"
|
|
>
|
|
<span
|
|
v-if="busy"
|
|
class="text-[10px] font-semibold text-muted"
|
|
aria-hidden="true"
|
|
>…</span>
|
|
<svg
|
|
v-else
|
|
class="admin-row-more-menu__icon shrink-0"
|
|
:class="iconSizeClass"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 -960 960 960"
|
|
fill="currentColor"
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M480-160q-33 0-56.5-23.5T400-240q0-33 23.5-56.5T480-320q33 0 56.5 23.5T560-240q0 33-23.5 56.5T480-160Zm0-240q-33 0-56.5-23.5T400-480q0-33 23.5-56.5T480-560q33 0 56.5 23.5T560-480q0 33-23.5 56.5T480-400Zm0-240q-33 0-56.5-23.5T400-720q0-33 23.5-56.5T480-800q33 0 56.5 23.5T560-720q0 33-23.5 56.5T480-640Z" />
|
|
</svg>
|
|
</button>
|
|
<Teleport to="body">
|
|
<div
|
|
v-if="isOpen"
|
|
class="admin-row-more-menu__popover fixed z-[80] overflow-hidden rounded-xl border border-[#e2e5e9] bg-white py-2 text-sm text-[#3f4650] shadow-[0_18px_50px_rgba(15,23,42,0.16)]"
|
|
:style="popoverStyle"
|
|
role="menu"
|
|
data-admin-row-menu
|
|
>
|
|
<slot />
|
|
</div>
|
|
</Teleport>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.admin-row-more-menu__popover :deep(.admin-row-more-menu__item) {
|
|
display: block;
|
|
width: 100%;
|
|
padding: 0.625rem 1rem;
|
|
text-align: left;
|
|
font-size: 0.875rem;
|
|
line-height: 1.25rem;
|
|
color: #3f4650;
|
|
}
|
|
|
|
.admin-row-more-menu__popover :deep(.admin-row-more-menu__item:hover) {
|
|
background: #f3f5f7;
|
|
}
|
|
|
|
.admin-row-more-menu__popover :deep(.admin-row-more-menu__item:disabled) {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.admin-row-more-menu__popover :deep(.admin-row-more-menu__item--danger) {
|
|
color: #c0392b;
|
|
}
|
|
|
|
.admin-row-more-menu__popover :deep(.admin-row-more-menu__item--danger:hover) {
|
|
background: #fef2f2;
|
|
}
|
|
</style>
|