-
Notifications
You must be signed in to change notification settings - Fork 147
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Use popperJS / teleport / vue-portal to render option list in modals #133
Comments
I think it's better to choose FloatingUI than PopperJS. |
This is an incredibly important feature to us as well! While we can use "overflow: visible" to resolve our issues with the scroll, the problem is that we sometimes NEED the scroll because the content of the modal is just too large. Thus the best option is to have it teleported elsewhere, like other libraries do. Really want to use vueform though as it provides all the best options. |
Just ran into a situation where I wanted more control over the position too. I have experience with https://www.primefaces.org/primevue/autocomplete and they offer a prop ( |
This possibility would be very important and appreciated. |
This is a really important feature. |
Absolutely important. |
Hey ya'll, I have a solution here that I developed internally. I would love some feedback from the dev team on this, and I'd be happy to convert to TS and get a PR going to allow for this sort of solution. How it works:
Issues:
Code:<script setup>
import { autoUpdate, computePosition, flip, size, offset } from '@floating-ui/dom';
/** other imports like ref, onMounted etc... **/
/** Defining necessary refs **/
/** The vueform/multiselect component **/
const multiselect = ref(null)
/** These two are used as the reference elements in floating-ui **/
const wrapperEl = ref(null)
const popoverEl = ref(null)
/** Target to append these elements to **/
const floatingElTarget = document.getElementById('floatingElements') ?? document.body
/** Cleanup function ref. Needs to be defined so we can always call it onBeforeUnmount **/
const cleanupDropdown = ref(() => {})
/** Positioning functions **/
//This function hacks the inner Multiselect component to stop deactivation when we click the portal dropdown
function handleFocusOut(e) {
if (e.relatedTarget && (popoverEl.value && popoverEl.value.contains(e.relatedTarget))) {
return
}
multiselect.value.deactivate()
}
// The other hacky bit - we need to find some DOM elements and manually move them in order to float the dropdown
function initDropdown() {
const wrapper = multiselect.value.multiselect
const popover = wrapper.querySelector('.ms-dropdown')
if (!popover || !wrapper || props.disableDropdown === true) {
return
}
popoverEl.value = popover
wrapperEl.value = wrapper
// The most dependable way to pop the dropdown out of the document flow is to move it to the root, so we do that.
// NOTE: the position: fixed floating-ui method was failing inside animate modals and any display:grid parent.
floatingElTarget.appendChild(popover)
multiselect.value.handleFocusOut = handleFocusOut
// AutoUpdate returns a cleanup function for removal on teardown.
const cleanup = autoUpdate(
wrapperEl.value,
popoverEl.value,
positionEls,
)
cleanupDropdown.value = () => {
// Not sure if this is needed
if (popoverEl.value && floatingElTarget.contains(popoverEl.value)) { floatingElTarget.removeChild(popoverEl.value) }
cleanup()
}
}
function positionEls() {
if (!popoverEl.value || !wrapperEl.value) {
return
}
// Allowing this to be configurable is VERY hairy.
computePosition(wrapperEl.value, popoverEl.value, {
strategy: 'absolute',
placement: 'bottom-start',
middleware: [
offset(8),
flip({
fallbackStrategy: 'initialPlacement',
fallbackPlacements: ['top-start', 'bottom-start']
}),
size({
padding: 20, // Sensible default for
apply ({ availableWidth, availableHeight, elements, rects }) {
Object.assign(elements.floating.style, {
minHeight: '1.3rem',
width: `${Math.max(rects.reference.width, 250)}px`,
maxWidth: `${availableWidth}px`,
maxHeight: `${availableHeight}px`, //These numbers are pleasant defaults/control for screen size.
})
},
}),
]
}).then(({x, y}) => {
Object.assign(popoverEl.value.style, {
left: `${x}px`,
top: `${y}px`,
})
})
}
/** All your other modelValue pass-through code, etc **/
/** Setup + Teardown **/
onMounted(() => {
initDropdown()
})
onBeforeUnmount(() => {
cleanupDropdown.value()
})
onUnmounted(() => {
cleanupDropdown.value()
})
</script>
<template>
<Multiselect
:id="id"
ref="multiselect"
...
@clear="(select$)=>select$.deactivate()"
@close="(select$)=>select$.deactivate()"
/>
</template> What could work:I think this should probably stay an implementation wrapper, unless the devs think they should set the floating-ui middleware configuration themselves. It would be helpful to expose the ms-dropdown/dropdown element as a ref so we don't have to use the DOM directly. It also would be VERY nice to have focus include the dropdown even if it's at the document root, though I'm not exactly sure how that would work. |
Composable version of the above (with slight changes) if anyone is interested: Key changes are:
import { autoUpdate, computePosition, flip, size } from '@floating-ui/dom'
interface useDetachedOptionsParams {
// The vueform/multiselect component ref
multiselect: Ref<any>
// Container to append dropdowns
appendTo?: MaybeRef<string> | MaybeRef<HTMLElement> | MaybeRef<string | HTMLElement>
}
export const useDetachedOptions = (options: useDetachedOptionsParams) => {
const disableDropdown = ref(false)
const wrapperEl = ref()
const popoverEl = ref()
const popoverInitialHeight = ref(0)
/** Container to append dropdowns **/
function getFloatingElTarget() {
const appendTo = unref(options.appendTo)
if (!appendTo) return document.getElementById('floatingElements') ?? document.body
if (typeof appendTo === 'string') return document.getElementById(appendTo) ?? document.body
return appendTo
}
const floatingElTarget = getFloatingElTarget()
/** Cleanup function ref. Needs to be defined so we can always call it onBeforeUnmount **/
const cleanupDropdown = ref(() => {})
/** Positioning functions **/
// This function hacks the inner Multiselect component to stop deactivation when we click the portal dropdown
function handleFocusOut(e: any) {
if (e.relatedTarget && popoverEl.value && popoverEl.value.contains(e.relatedTarget)) {
return
}
options.multiselect.value.deactivate()
}
// The other hacky bit - we need to find some DOM elements and manually move them in order to float the dropdown
function initDropdown() {
const wrapper = options.multiselect.value.$el
const popover = wrapper.querySelector('.multiselect-dropdown')
if (!popover || !wrapper || disableDropdown.value === true) {
return
}
popoverEl.value = popover
wrapperEl.value = wrapper
// Get initial options height
const display = getComputedStyle(popover).getPropertyValue('display')
if (display === 'none') {
popover.style.display = 'flex'
}
popoverInitialHeight.value = popover.offsetHeight
popover.style.removeProperty('display')
// Set defaults required by floating-ui
Object.assign(popover.style, {
position: 'absolute',
width: 'max-content',
top: 0,
left: 0,
right: 'auto',
bottom: 'auto',
transform: 'none',
marginTop: 0,
})
floatingElTarget.appendChild(popover)
options.multiselect.value.handleFocusOut = handleFocusOut
// autoUpdate returns a cleanup function for removal on teardown.
const cleanup = autoUpdate(wrapperEl.value, popoverEl.value, positionEls)
cleanupDropdown.value = () => {
if (popoverEl.value && floatingElTarget.contains(popoverEl.value)) {
floatingElTarget.removeChild(popoverEl.value)
}
cleanup()
}
}
function positionEls() {
if (!popoverEl.value || !wrapperEl.value) {
return
}
computePosition(wrapperEl.value, popoverEl.value, {
strategy: 'absolute',
placement: 'bottom-start',
middleware: [
flip({
fallbackStrategy: 'initialPlacement',
fallbackPlacements: ['top-start', 'bottom-start'],
}),
size({
apply({ availableWidth, availableHeight, elements, rects }) {
Object.assign(elements.floating.style, {
minHeight: `${popoverInitialHeight.value}px`,
width: `${rects.reference.width}px`,
maxWidth: `${availableWidth}px`,
maxHeight: `${availableHeight}px`, // These numbers are pleasant defaults/control for screen size.
})
},
}),
],
}).then(({ x, y }) => {
Object.assign(popoverEl.value.style, {
left: `${x}px`,
top: `${y}px`,
})
})
}
onMounted(() => {
initDropdown()
})
onBeforeUnmount(() => {
cleanupDropdown.value()
})
onUnmounted(() => {
cleanupDropdown.value()
})
} You can use it in your Multiselect wrapper like that: <script setup lang="ts">
const props = defineProps<{
detached?: boolean
appendTo?: string | HTMLElement
}>()
const appendToRef = toRef(() => props.appendTo)
const multiselectRef = ref()
if (props.detached) {
useDetachedOptions({
multiselect: multiselectRef,
appendTo: appendToRef,
})
}
</script>
<template>
<Multiselect
ref="multiselectRef"
/>
</template> One could make it so composable watches detached prop and disables/enables according to it but I don't feel the need for that. I think it's fine to just set it on initialization. |
Thank you for the patience guys. It's now implemented in |
Tt works great thank you @adamberecz ! |
When using multiselect in a modal, the option list is rendered in the modal, and if the modal body is not high enough, it forces the user to scroll the modal body to see the option list, which is not user-friendly.
I saw the option
openDirection
but I have some modals with dynamic height, and always openning the list to the top is not ideal.With teleport (vue3) and portal-vue (vue2), you could add an option
appendTo
that will allow us to choose where to render the option list (for instance, in the body when used in a modal).Edit: PopperJS may be a better solution as it also do all the positionning
The text was updated successfully, but these errors were encountered: