@coreui/vue-pro
Version:
UI Components Library for Vue.js
378 lines (345 loc) • 11.5 kB
text/typescript
import {
computed,
defineComponent,
h,
nextTick,
onUnmounted,
provide,
PropType,
ref,
Ref,
watch,
} from 'vue'
import type { Placement } from '@popperjs/core'
import { usePopper } from '../../composables'
import type { Triggers } from '../../types'
import { getNextActiveElement, isRTL } from '../../utils'
import type { Alignments } from './types'
import { getPlacement, getReferenceElement } from './utils'
import { CFocusTrap } from '../focus-trap'
const CDropdown = defineComponent({
name: 'CDropdown',
props: {
/**
* Set aligment of dropdown menu.
*
* @values { 'start' | 'end' | { xs: 'start' | 'end' } | { sm: 'start' | 'end' } | { md: 'start' | 'end' } | { lg: 'start' | 'end' } | { xl: 'start' | 'end'} | { xxl: 'start' | 'end'} }
*/
alignment: {
type: [String, Object] as PropType<string | Alignments>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
validator: (value: string | any) => {
if (value === 'start' || value === 'end') {
return true
} else {
if (value.xs !== undefined && (value.xs === 'start' || value.xs === 'end')) {
return true
}
if (value.sm !== undefined && (value.sm === 'start' || value.sm === 'end')) {
return true
}
if (value.md !== undefined && (value.md === 'start' || value.md === 'end')) {
return true
}
if (value.lg !== undefined && (value.lg === 'start' || value.lg === 'end')) {
return true
}
if (value.xl !== undefined && (value.xl === 'start' || value.xl === 'end')) {
return true
}
if (value.xxl !== undefined && (value.xxl === 'start' || value.xxl === 'end')) {
return true
}
return false
}
},
},
/**
* Configure the auto close behavior of the dropdown:
* - `true` - the dropdown will be closed by clicking outside or inside the dropdown menu.
* - `false` - the dropdown will be closed by clicking the toggle button and manually calling hide or toggle method. (Also will not be closed by pressing esc key)
* - `'inside'` - the dropdown will be closed (only) by clicking inside the dropdown menu.
* - `'outside'` - the dropdown will be closed (only) by clicking outside the dropdown menu.
*/
autoClose: {
type: [Boolean, String] as PropType<boolean | 'inside' | 'outside'>,
default: true,
validator: (value: boolean | string) => {
return typeof value === 'boolean' || ['inside', 'outside'].includes(value)
},
},
/**
* Appends the vue dropdown menu to a specific element. You can pass an HTML element or function that returns a single element. By default `document.body`.
*
* @since 5.0.0
*/
container: {
type: [Object, String] as PropType<HTMLElement | (() => HTMLElement) | string>,
default: 'body',
},
/**
* Sets a darker color scheme to match a dark navbar.
*/
dark: Boolean,
/**
* Sets a specified direction and location of the dropdown menu.
*
* @values 'center', 'dropup', 'dropup-center', 'dropend', 'dropstart'
*/
direction: {
type: String,
validator: (value: string) => {
return ['center', 'dropup', 'dropup-center', 'dropend', 'dropstart'].includes(value)
},
},
/**
* Toggle the disabled state for the component.
*/
disabled: Boolean,
/**
* Offset of the dropdown menu relative to its target.
*
* @since 4.9.0
*/
offset: {
type: Array,
default: () => [0, 2],
},
/**
* Describes the placement of your component after Popper.js has applied all the modifiers that may have flipped or altered the originally provided placement property.
*
* @values 'auto', 'top-end', 'top', 'top-start', 'bottom-end', 'bottom', 'bottom-start', 'right-start', 'right', 'right-end', 'left-start', 'left', 'left-end'
*/
placement: {
type: String as PropType<Placement>,
default: 'bottom-start',
},
/**
* If you want to disable dynamic positioning set this property to `true`.
*/
popper: {
type: Boolean,
default: true,
},
/**
* Sets the reference element for positioning the Vue Dropdown Menu.
* - `toggle` - The Vue Dropdown Toggle button (default).
* - `parent` - The Vue Dropdown wrapper element.
* - `HTMLElement` - A custom HTML element.
* - `Ref` - A custom reference element.
*
* @since 5.7.0
*/
reference: {
type: [String, Object] as PropType<
'parent' | 'toggle' | HTMLElement | Ref<HTMLElement | null>
>,
default: 'toggle',
},
/**
* Generates dropdown menu using Teleport.
*
* @since 5.0.0
*/
teleport: {
type: Boolean,
default: false,
},
/**
* Sets which event handlers you’d like provided to your toggle prop. You can specify one trigger or an array of them.
*/
trigger: {
type: String as PropType<Triggers>,
default: 'click',
},
/**
* Set the dropdown variant to an btn-group, dropdown, input-group, and nav-item.
*
* @values 'btn-group', 'dropdown', 'input-group', 'nav-item'
*/
variant: {
type: String,
default: 'btn-group',
validator: (value: string) => {
return ['btn-group', 'dropdown', 'input-group', 'nav-item'].includes(value)
},
},
/**
* Toggle the visibility of dropdown menu component.
*/
visible: Boolean,
},
emits: [
/**
* Callback fired when the component requests to be hidden.
*/
'hide',
/**
* Callback fired when the component requests to be shown.
*/
'show',
],
setup(props, { slots, emit }) {
const dropdownRef = ref<HTMLElement | null>(null)
const dropdownMenuRef = ref<HTMLElement | null>(null)
const dropdownToggleRef = ref<HTMLElement | null>(null)
const pendingKeyDownEventRef = ref<KeyboardEvent | null>(null)
const popper = ref(typeof props.alignment === 'object' ? false : props.popper)
const visible = ref(props.visible)
const { initPopper, destroyPopper } = usePopper()
const popperConfig = computed(() => ({
modifiers: [
{
name: 'offset',
options: {
offset: props.offset,
},
},
],
placement: getPlacement(
props.placement,
props.direction,
props.alignment,
isRTL(dropdownMenuRef.value)
) as Placement,
}))
watch(
() => props.visible,
() => {
visible.value = props.visible
}
)
watch(visible, () => {
if (visible.value && dropdownToggleRef.value && dropdownMenuRef.value) {
const referenceElement = getReferenceElement(
props.reference,
dropdownToggleRef,
dropdownRef
)
if (referenceElement && popper.value) {
initPopper(referenceElement, dropdownMenuRef.value, popperConfig.value)
}
window.addEventListener('click', handleClick)
window.addEventListener('keyup', handleKeyup)
dropdownToggleRef.value.addEventListener('keydown', handleKeydown)
dropdownMenuRef.value.addEventListener('keydown', handleKeydown)
if (pendingKeyDownEventRef.value) {
nextTick(() => {
handleKeydown(pendingKeyDownEventRef.value as KeyboardEvent)
pendingKeyDownEventRef.value = null
})
}
emit('show')
return
}
if (popper.value) {
destroyPopper()
}
window.removeEventListener('click', handleClick)
window.removeEventListener('keyup', handleKeyup)
dropdownMenuRef.value && dropdownMenuRef.value.removeEventListener('keydown', handleKeydown)
dropdownToggleRef.value &&
dropdownToggleRef.value.removeEventListener('keydown', handleKeydown)
emit('hide')
})
onUnmounted(() => {
dropdownToggleRef.value &&
dropdownToggleRef.value.removeEventListener('keydown', handleKeydown)
dropdownMenuRef.value && dropdownMenuRef.value.removeEventListener('keydown', handleKeydown)
})
provide('config', {
alignment: props.alignment,
container: props.container,
dark: props.dark,
popper: props.popper,
teleport: props.teleport,
})
provide('variant', props.variant)
provide('visible', visible)
provide('dropdownToggleRef', dropdownToggleRef)
provide('dropdownMenuRef', dropdownMenuRef)
provide('pendingKeyDownEventRef', pendingKeyDownEventRef)
const handleKeydown = (event: KeyboardEvent) => {
if (dropdownMenuRef.value && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) {
event.preventDefault()
const target = event.target as HTMLElement
const items: HTMLElement[] = Array.from(
dropdownMenuRef.value.querySelectorAll('.dropdown-item:not(.disabled):not(:disabled)')
)
getNextActiveElement(items, target, event.key === 'ArrowDown', true).focus()
}
}
const handleKeyup = (event: KeyboardEvent) => {
if (props.autoClose === false) {
return
}
if (event.key === 'Escape') {
setVisible(false)
dropdownToggleRef.value?.focus()
}
}
const handleClick = (event: Event) => {
if (!dropdownToggleRef.value || !dropdownMenuRef.value) {
return
}
if ((event as MouseEvent).button === 2) {
return
}
const composedPath = event.composedPath()
const isOnToggle = composedPath.includes(dropdownToggleRef.value)
const isOnMenu = composedPath.includes(dropdownMenuRef.value)
if (isOnToggle) {
return
}
const target = event.target as HTMLElement | null
const FORM_TAG_RE = /^(input|select|option|textarea|form|button|label)$/i
if (isOnMenu && target && FORM_TAG_RE.test(target.tagName)) {
return
}
if (
props.autoClose === true ||
(props.autoClose === 'inside' && isOnMenu) ||
(props.autoClose === 'outside' && !isOnMenu)
) {
setVisible(false)
}
}
const setVisible = (_visible?: boolean, event?: KeyboardEvent) => {
if (props.disabled) {
return
}
if (typeof _visible === 'boolean') {
if (event) {
pendingKeyDownEventRef.value = event || null
}
visible.value = _visible
return
}
}
provide('setVisible', setVisible)
return () =>
h(
CFocusTrap,
{ active: props.teleport && visible.value, additionalContainer: dropdownMenuRef },
() =>
props.variant === 'input-group'
? [slots.default && slots.default()]
: h(
'div',
{
class: [
props.variant === 'nav-item' ? 'nav-item dropdown' : props.variant,
props.direction === 'center'
? 'dropdown-center'
: props.direction === 'dropup-center'
? 'dropup dropup-center'
: props.direction,
],
ref: dropdownRef,
},
slots.default && slots.default()
)
)
},
})
export { CDropdown }