Skip to content

Instantly share code, notes, and snippets.

@plmrlnsnts
Last active December 23, 2020 17:33
Show Gist options
  • Save plmrlnsnts/4587f5856608140609f27a29d77d052f to your computer and use it in GitHub Desktop.
Save plmrlnsnts/4587f5856608140609f27a29d77d052f to your computer and use it in GitHub Desktop.
Headless dropdown component for Vue.js

Usage

<dropdown-menu v-slot="{ open, close }">
    <dropdown-toggle @click="open">
        Dropdown
    </dropdown-toggle>
    <dropdown-menu>
        <a href="#item-1" @click="close">Item 1</a>
        <a href="#item-2" @click="close">Item 2</a>
        <a href="#item-3" @click="close">Item 3</a>
    </dropdown-menu>
</dropdown-menu>

Dependencies

Components

Dropdown.vue

<template>
    <span v-on-clickaway="close">
        <slot v-bind="{ open, close }"></slot>
    </span>
</template>

<script>
import { mixin as clickaway } from 'vue-clickaway'

export default {
    mixins: [clickaway],

    data: vm => ({
        id: `dropdown-${vm._uid}`,
        status: 'closed',
    }),

    mounted () {
        let onKeydown = (event) => {
            if (this.isClosed()) return

            if (event.key === 'Escape') {
                event.preventDefault()
                this.close()
            }
        }

        document.addEventListener('keydown', onKeydown)

        this.$once('hook:beforeDestroy', () => {
            document.removeEventListener('keydown', onKeydown)
        })
    },

    methods: {
        open () {
            this.status = 'opened'
        },

        close () {
            this.status = 'closed'
        },

        isOpened () {
            return this.status === 'opened'
        },

        isClosed () {
            return this.status === 'closed'
        }
    },
}
</script>

DropdownMenu.vue

<template>
    <component
        role="menu"
        v-if="$parent.isOpened()"
        :aria-labelledby="`${$parent.id}-toggle`"
        :id="`${$parent.id}-menu`"
        :is="as"
    >
        <slot />
    </component>
</template>

<script>
import { createPopper } from '@popperjs/core'

export default {
    props: {
        as: {
            type: String,
            default: 'div'
        },

        placement: {
            type: String,
            default: 'bottom-end'
        },

        offset: {
            type: Array,
            default: function () {
                return [0, 8]
            }
        },
    },

    data: (vm) => ({
        focusedChild: -1,
        popperOptions: {
            placement: vm.$props.placement,
            modifiers: {
                name: 'offset',
                options: {
                    offset: vm.$props.offset,
                }
            }
        }
    }),

    mounted () {
        let onKeydown = (event) => {
            if (this.$parent.isClosed()) return

            switch (event.key) {
                case 'Tab':
                    event.preventDefault()
                    event.shiftKey ? this.previous() : this.next()
                break;
                case 'Enter':
                    event.preventDefault()
                    if (this.focusedChild === -1) return
                    this.focusableChildren()[this.focusedChild].click()
                break;
                case 'Escape':
                    event.preventDefault()
                    this.close()
                break;
                case 'ArrowUp':
                    event.preventDefault()
                    this.previous()
                break;
                case 'ArrowDown':
                    event.preventDefault()
                    this.next()
                break;
                case 'Home':
                    event.preventDefault()
                    this.focusedChild = 0
                break;
                case 'End':
                    event.preventDefault()
                    this.focusedChild = this.focusableChildren().length - 1
                break;
            }
        }

        document.addEventListener('keydown', onKeydown)

        this.$once('hook:beforeDestroy', () => {
            document.removeEventListener('keydown', onKeydown)
        })
    },

    methods: {
        previous () {
            this.focusedChild = this.focusedChild > 0
                ? this.focusedChild - 1
                : this.focusableChildren().length - 1
        },

        next () {
            this.focusedChild = this.focusedChild < (this.focusableChildren().length - 1)
                ? this.focusedChild + 1
                : 0
        },

        focusableChildren () {
            return this.$el.querySelectorAll([
                'a, button, input, textarea, select',
                'details, [tabindex]:not([tabindex="-1"])'
            ].join(','))
        },
    },

    watch: {
        focusedChild: function (value) {
            if (value < 0) return
            this.focusableChildren()[value].focus()
        },

        "$parent.status": function (value) {
            if (value === 'closed') return this.popper.destroy()

            this.$nextTick(() => this.popper = createPopper(
                this.$parent.$el, this.$el, this.popperOptions
            ))

            this.focusedChild = -1
        },
    }
}
</script>

DropdownToggle.vue

<template>
    <component
        :is="as"
        :id="`${$parent.id}-toggle`"
        :aria-expanded="$parent.isOpened()"
        :aria-controls="$parent.isOpened() ? `${$parent.id}-menu` : false"
        v-on="$listeners"
    >
        <slot />
    </component>
</template>

<script>
export default {
    props: {
        as: {
            type: String,
            default: 'button'
        }
    },
}
</script>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment