multiselect icon indicating copy to clipboard operation
multiselect copied to clipboard

Use popperJS / teleport / vue-portal to render option list in modals

Open Finrod927 opened this issue 2 years ago • 2 comments

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

Finrod927 avatar Sep 16 '21 08:09 Finrod927

I think it's better to choose FloatingUI than PopperJS.

negezor avatar Jun 06 '22 08:06 negezor

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.

LanFeusT23 avatar Sep 08 '22 18:09 LanFeusT23

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 (appendTo)that allows for more control over the position. It would be great if you could support something too. Any updates?

robokozo avatar Oct 20 '22 16:10 robokozo

This possibility would be very important and appreciated.

andorfermichael avatar Dec 21 '22 06:12 andorfermichael

This is a really important feature.

KelvinCampelo avatar Mar 30 '23 14:03 KelvinCampelo

Absolutely important.

UteV avatar Apr 03 '23 08:04 UteV

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:

  • The template is a wrapper around the Multiselect.vue component.
  • It uses one dependency (@floatingui/vue)
  • I am using document.getElementById to append this to a Portal target, but this could be an appendTo argument as well.

Issues:

  • The multiselect currently closes/deactivates on parent/input focusOut. When we move the dropdown to the root, it's considered outside the scope for focus, so we currently have to hack the onFocusOut behavior in order for this to work.
  • We are currently using multiselect.querySelector (NO) in order to get the dropdown element, because that isn't exposed.
  • Middleware settings are hard to allow flexibility.

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.

evankford avatar Apr 25 '23 20:04 evankford

Composable version of the above (with slight changes) if anyone is interested:

Key changes are:

  • grabbing initial height from dropdown and using it as minHeight for size floating-ui middleware
  • deleted hardcorded minWidth (250px)
  • deleted offset middleware and size middleware padding
  • setting default styles required by floating-ui to work before calculating position
  • container to append dropdown is now configurable
  • multiselect ref comes from composable options
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.

KamilBeda avatar Aug 17 '23 18:08 KamilBeda

Thank you for the patience guys. It's now implemented in 2.6.3 and can be enabled with appendToBody: true. It's still experimental and only works in Vue.js 3 so please open an issue if you encounter any problems.

adamberecz avatar Oct 07 '23 15:10 adamberecz

Tt works great thank you @adamberecz !

LanFeusT23 avatar Oct 13 '23 19:10 LanFeusT23