You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

156 lines
3.8 KiB

<template>
<Teleport :to="teleportTo" :disabled="!mounted">
<Transition :name="maskTransition" appear>
<div
v-if="showMask && isOpen"
:class="e('mask')"
@click="onMaskClick"
/>
</Transition>
<Transition :name="panelTransition" appear>
<div
v-show="isOpen"
ref="panelRef"
:class="panelClasses"
:style="panelStyle"
role="dialog"
aria-modal="true"
:aria-label="ariaLabel"
tabindex="-1"
@keydown="onKeydown"
>
<slot />
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useNamespace } from 'bolt-ui/utils/hooks/use-namespace'
const props = withDefaults(
defineProps<{
open: boolean
side?: 'left' | 'right'
width?: string | number
closeOnMask?: boolean
closeOnEsc?: boolean
showMask?: boolean
zIndex?: number
transitionDuration?: number
teleportTo?: string
ariaLabel?: string
}>(),
{
side: 'left',
width: '80%',
closeOnMask: true,
closeOnEsc: true,
showMask: true,
zIndex: 200,
transitionDuration: 280,
teleportTo: 'body',
ariaLabel: '抽屉',
},
)
const emit = defineEmits<{
'update:open': [v: boolean]
}>()
const { b, e, m } = useNamespace('drawer')
const mounted = ref(false)
const panelRef = ref<HTMLElement | null>(null)
const isOpen = ref(props.open)
let prevOverflow = ''
let lastFocusedTrigger: HTMLElement | null = null
const panelClasses = computed(() => [b(), m(props.side)])
const panelStyle = computed(() => ({
width: typeof props.width === 'number' ? `${props.width}px` : props.width,
zIndex: String(props.zIndex),
transitionDuration: `${props.transitionDuration}ms`,
}))
const panelTransition = computed(() => `bo-drawer-panel-${props.side}-fade`)
const maskTransition = 'bo-drawer-mask-fade'
watch(
() => props.open,
async (next) => {
if (typeof window === 'undefined') return
isOpen.value = next
if (next) {
lastFocusedTrigger = (document.activeElement as HTMLElement | null) ?? null
prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
await nextTick()
focusFirst()
} else {
document.body.style.overflow = prevOverflow
lastFocusedTrigger?.focus?.()
}
},
{ immediate: true },
)
function onMaskClick() {
if (props.closeOnMask) emit('update:open', false)
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && props.closeOnEsc) {
emit('update:open', false)
e.stopPropagation()
}
if (e.key === 'Tab') trapFocus(e)
}
function focusFirst() {
const root = panelRef.value
if (!root) return
const focusable = getFocusable(root)
if (focusable.length > 0) {
focusable[0]?.focus?.()
} else {
root.focus()
}
}
function getFocusable(root: HTMLElement): HTMLElement[] {
const sel = 'a[href],button:not([disabled]),[tabindex]:not([tabindex="-1"]),input:not([disabled]),select:not([disabled]),textarea:not([disabled])'
return Array.from(root.querySelectorAll<HTMLElement>(sel))
}
function trapFocus(e: KeyboardEvent) {
const root = panelRef.value
if (!root) return
const focusable = getFocusable(root)
if (focusable.length === 0) {
e.preventDefault()
return
}
const first = focusable[0]!
const last = focusable[focusable.length - 1]!
const active = document.activeElement as HTMLElement | null
if (e.shiftKey && active === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && active === last) {
e.preventDefault()
first.focus()
}
}
onMounted(() => {
mounted.value = true
})
onBeforeUnmount(() => {
document.body.style.overflow = prevOverflow
})
defineOptions({ name: 'BoDrawer' })
</script>