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
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>
|
|
|