206 lines
5.7 KiB
Vue
206 lines
5.7 KiB
Vue
<script>
|
|
import {
|
|
autoUpdate,
|
|
flip,
|
|
offset,
|
|
shift,
|
|
size,
|
|
useFloating,
|
|
} from '@floating-ui/vue'
|
|
import {
|
|
cloneVNode,
|
|
computed,
|
|
h,
|
|
mergeProps,
|
|
nextTick,
|
|
onBeforeUnmount,
|
|
onMounted,
|
|
ref,
|
|
Teleport,
|
|
Transition,
|
|
watch,
|
|
withModifiers,
|
|
} from 'vue'
|
|
import { useId } from '../../composables/useId'
|
|
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
|
|
import { renderSlotFragments } from '../../util/renderSlotFragments'
|
|
import { useCloseOnEsc } from '../../composables/useCloseOnEsc'
|
|
|
|
export default {
|
|
emits: ['menu-opened', 'menu-closed'],
|
|
|
|
inheritAttrs: false,
|
|
|
|
props: {
|
|
offset: { type: [Number, String], default: 5 },
|
|
placement: { type: String, default: 'bottom-start' },
|
|
boundary: { type: String, default: 'viewPort' },
|
|
dusk: { type: String, default: null },
|
|
shouldCloseOnBlur: { type: Boolean, default: true },
|
|
},
|
|
|
|
setup(props, { slots }) {
|
|
const menuShown = ref(false)
|
|
const triggerRef = ref(null)
|
|
const teleportedRef = ref(null)
|
|
const menuRef = ref(null)
|
|
|
|
const { activate, deactivate } = useFocusTrap(menuRef, {
|
|
initialFocus: false,
|
|
allowOutsideClick: true,
|
|
})
|
|
|
|
const usesFocusTrap = ref(true)
|
|
|
|
const hasTrapFocus = computed(() => {
|
|
return menuShown.value === true && usesFocusTrap.value === true
|
|
})
|
|
|
|
const disableModalFocusTrap = () => {
|
|
usesFocusTrap.value = false
|
|
}
|
|
|
|
const enableModalFocusTrap = () => {
|
|
usesFocusTrap.value = true
|
|
}
|
|
|
|
useCloseOnEsc(() => (menuShown.value = false))
|
|
|
|
const dropdownButtonLabel = computed(
|
|
() => `nova-ui-dropdown-button-${useId()}`
|
|
)
|
|
const menuLabel = computed(() => `nova-ui-dropdown-menu-${useId()}`)
|
|
|
|
const resolvedPlacement = computed(() => {
|
|
if (!Nova.config('rtlEnabled')) {
|
|
return props.placement
|
|
}
|
|
|
|
return {
|
|
'auto-start': 'auto-end',
|
|
'auto-end': 'auto-start',
|
|
'top-start': 'top-end',
|
|
'top-end': 'top-start',
|
|
'bottom-start': 'bottom-end',
|
|
'bottom-end': 'bottom-start',
|
|
'right-start': 'right-end',
|
|
'right-end': 'right-start',
|
|
'left-start': 'left-end',
|
|
'left-end': 'left-start',
|
|
}[props.placement]
|
|
})
|
|
|
|
const { floatingStyles } = useFloating(triggerRef, menuRef, {
|
|
whileElementsMounted: autoUpdate,
|
|
placement: resolvedPlacement.value,
|
|
middleware: [offset(props.offset), flip(), shift({ padding: 5 }), size()],
|
|
})
|
|
|
|
watch(
|
|
() => hasTrapFocus,
|
|
async v => {
|
|
await nextTick()
|
|
v ? activate() : deactivate()
|
|
}
|
|
)
|
|
|
|
onMounted(() => {
|
|
Nova.$on('disable-focus-trap', disableModalFocusTrap)
|
|
Nova.$on('enable-focus-trap', enableModalFocusTrap)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
Nova.$off('disable-focus-trap', disableModalFocusTrap)
|
|
Nova.$off('enable-focus-trap', enableModalFocusTrap)
|
|
|
|
usesFocusTrap.value = false
|
|
})
|
|
|
|
return () => {
|
|
const children = renderSlotFragments(slots.default())
|
|
const [trigger, ...otherChildren] = children
|
|
|
|
const mergedProps = mergeProps({
|
|
...trigger.props,
|
|
...{
|
|
id: dropdownButtonLabel.value,
|
|
'aria-expanded': menuShown.value === true ? 'true' : 'false',
|
|
'aria-haspopup': 'true',
|
|
'aria-controls': menuLabel.value,
|
|
onClick: withModifiers(() => {
|
|
menuShown.value = !menuShown.value
|
|
}, ['stop']),
|
|
},
|
|
})
|
|
|
|
const cloned = cloneVNode(trigger, mergedProps)
|
|
|
|
// Explicitly override props starting with `on`.
|
|
// It seems cloneVNode from Vue doesn't like overriding `onXXX` props. So
|
|
// we have to do it manually.
|
|
for (const prop in mergedProps) {
|
|
if (prop.startsWith('on')) {
|
|
cloned.props ||= {}
|
|
cloned.props[prop] = mergedProps[prop]
|
|
}
|
|
}
|
|
|
|
return h('div', { dusk: props.dusk }, [
|
|
h('span', { ref: triggerRef }, cloned),
|
|
h(
|
|
Teleport,
|
|
{ to: 'body' },
|
|
h(
|
|
Transition,
|
|
{
|
|
enterActiveClass: 'transition duration-0 ease-out',
|
|
enterFromClass: 'opacity-0',
|
|
enterToClass: 'opacity-100',
|
|
leaveActiveClass: 'transition duration-300 ease-in',
|
|
leaveFromClass: 'opacity-100',
|
|
leaveToClass: 'opacity-0',
|
|
},
|
|
() => [
|
|
menuShown.value
|
|
? h(
|
|
'div',
|
|
{
|
|
ref: teleportedRef,
|
|
dusk: 'dropdown-teleported',
|
|
},
|
|
[
|
|
h(
|
|
'div',
|
|
{
|
|
ref: menuRef,
|
|
id: menuLabel.value,
|
|
'aria-labelledby': dropdownButtonLabel.value,
|
|
tabindex: '0',
|
|
class: 'relative z-[70]',
|
|
style: floatingStyles.value,
|
|
'data-menu-open': menuShown.value,
|
|
dusk: 'dropdown-menu',
|
|
onClick: () =>
|
|
props.shouldCloseOnBlur
|
|
? (menuShown.value = false)
|
|
: null,
|
|
},
|
|
slots.menu()
|
|
),
|
|
h('div', {
|
|
class: 'z-[69] fixed inset-0',
|
|
dusk: 'dropdown-overlay',
|
|
onClick: () => (menuShown.value = false),
|
|
}),
|
|
]
|
|
)
|
|
: null,
|
|
]
|
|
)
|
|
),
|
|
])
|
|
}
|
|
},
|
|
}
|
|
</script>
|