Skip to content
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

Closed
Finrod927 opened this issue Sep 16, 2021 · 10 comments
Closed
Labels
enhancement New feature or request upcoming

Comments

@Finrod927
Copy link

Finrod927 commented Sep 16, 2021

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 Finrod927 changed the title Use teleport or vue-portal to render option list Use popperJS / teleport / vue-portal to render option list in modals Oct 19, 2021
@adamberecz adamberecz added the enhancement New feature or request label Dec 16, 2021
@negezor
Copy link
Contributor

negezor commented Jun 6, 2022

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

@LanFeusT23
Copy link

LanFeusT23 commented Sep 8, 2022

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.

@robokozo
Copy link

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?

@andorfermichael
Copy link

This possibility would be very important and appreciated.

@KelvinCampelo
Copy link

This is a really important feature.

@UteV
Copy link

UteV commented Apr 3, 2023

Absolutely important.

@evankford
Copy link

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.

@KamilBeda
Copy link

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.

adamberecz added a commit that referenced this issue Oct 6, 2023
@adamberecz
Copy link
Collaborator

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.

@LanFeusT23
Copy link

Tt works great thank you @adamberecz !

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request upcoming
Projects
None yet
Development

No branches or pull requests

10 participants