BpDirectional is a renderless Vue component that implements keyboard navigation for elements within it. This includes arrow keys as well as Home and End.

Keyboard navigation is done based on screen layout - that means that even if content reflows based on breakpoints, directional keys should still move focus how the user would expect.

<style>
    li a {
        display: block;
    }
</style>
<main>
    <bp-directional>
        <ul style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));">
            <li>
                <a href="#"><span>Consectetur non laborum!</span></a>
            </li>
            <li>
                <a href="#"><span>Adipisicing natus laboriosam?</span></a>
            </li>
            <li>
                <a href="#"><span>Dolor accusamus quis</span></a>
            </li>
            <li>
                <a href="#"><span>Elit iste amet.</span></a>
            </li>
            <li>
                <a href="#"><span>Elit nulla provident?</span></a>
            </li>
            <li>
                <a href="#"><span>Adipisicing sit quae?</span></a>
            </li>
            <li>
                <a href="#"><span>Dolor quisquam explicabo.</span></a>
            </li>
            <li>
                <a href="#"><span>Adipisicing molestiae facilis.</span></a>
            </li>
            <li>
                <a href="#"><span>Ipsum officiis ipsa?</span></a>
            </li>
            <li>
                <a href="#"><span>Lorem possimus nam.</span></a>
            </li>
            <li>
                <a href="#"><span>Adipisicing provident ipsum</span></a>
            </li>
            <li>
                <a href="#"><span>Amet odio repudiandae.</span></a>
            </li>
            <li>
                <a href="#"><span>Consectetur impedit praesentium!</span></a>
            </li>
            <li>
                <a href="#"><span>Dolor sunt praesentium</span></a>
            </li>
            <li>
                <a href="#"><span>Adipisicing molestias quis</span></a>
            </li>
            <li>
                <a href="#"><span>Adipisicing aspernatur cum.</span></a>
            </li>
            <li>
                <a href="#"><span>Dolor numquam aut.</span></a>
            </li>
        </ul>
    </bp-directional>

</main>
  • Content:
    <script>
    import Vue from 'vue'
    
    /**
     * Default selector used for all focusable elements.
     */
    const FOCUSABLE_SELECTOR = [
        'a[href]',
        'audio[controls]',
        'button',
        'details summary',
        'input',
        'map area[href]',
        'select',
        'svg a[xlink\\:href]',
        '[tabindex]',
        'textarea',
        'video[controls]',
    ].map(t => t + ':not([tabindex^="-"]):not([disabled])').join()
    
    /**
     * Default key filters. These should be functions that 'filter out' what
     * elements should be considered when a key is hit.
     */
    const KEY_FILTERS = {
        ArrowRight: elements => elements.filter(({x, top, bottom}) => x > 0 && top < 0 && bottom > 0),
        ArrowLeft: elements => elements.filter(({x, top, bottom}) => x < 0 && top < 0 && bottom > 0),
        ArrowDown: elements => elements.filter(({y, left, right}) => y > 0 && left < 0 && right > 0),
        ArrowUp: elements => elements.filter(({y, left, right}) => y < 0 && left < 0 && right > 0),
        Home: elements => elements.length && elements.slice(0, 1),
        End: elements => elements.length && elements.slice(-1)
    }
    
    export default {
        methods: {
            /**
             * Method for retrieving all currently visible, focusable elements.
             */
            queryFocusableElements() {
                return this.$el.querySelectorAll(FOCUSABLE_SELECTOR)
            },
    
            /**
             * Retrieves where the element is drawn on screen (client rects).
             * Adds in x and y for the center of the element.
             */
            getElementRects(el) {
                const rects = el.getClientRects()[0]
    
                if (!rects || !rects.left) {
                    return null
                }
    
                return {
                    bottom: rects.bottom,
                    height: rects.height,
                    left: rects.left,
                    right: rects.right,
                    top: rects.top,
                    width: rects.width,
                    x: rects.left + rects.width / 2,
                    y: rects.top + rects.height / 2
                }
            },
    
            /**
             * Calls getElementRects for every element in nodeList, and adjust the
             * values to be relative to origin for simpler follow up math. Also
             * calculates the distance between the two elements' centers.
             */
            augmentElementRects(nodeList, origin) {
                const elements = []
                origin = this.getElementRects(origin)
    
                if (!origin) {
                    return elements
                }
    
                nodeList.forEach(el => {
                    let rects = this.getElementRects(el)
                    if (rects === null) {
                        return
                    }
    
                    rects.bottom -= origin.y
                    rects.left -= origin.x
                    rects.right -= origin.x
                    rects.top -= origin.y
                    rects.x -= origin.x
                    rects.y -= origin.y
    
                    const distance = Math.sqrt(rects.x * rects.x + rects.y * rects.y)
    
                    elements.push({ el, ...rects, distance })
                })
    
                return elements
            },
    
            /**
             * Returns the filter function for key, or a function that always returns false.
             */
            filterForKey(key) {
                return key in KEY_FILTERS ? KEY_FILTERS[key] : null
            },
    
            /**
             * returns the closest, focusable element to el that passes the filter
             * function key.
             */
            findTarget(el, key) {
                const elements = this.augmentElementRects(this.queryFocusableElements(), el)
                const keyFilter = this.filterForKey(key)
                if (elements.length && keyFilter) {
                    return keyFilter(elements)
                        .reduce((closest, n) => n.distance < closest.distance ?  n : closest, { distance: Infinity })
                        .el
                }
                return null
            },
    
            /**
             * Event handler; tries to find an element to move to; if it does,
             * changes focus, otherwise just lets teh event bubble up.
             */
            handler(evt) {
                const newTarget = this.findTarget(evt.target, evt.key)
                if (newTarget) {
                    evt.preventDefault()
                    evt.stopPropagation()
                    newTarget.focus()
                }
            }
        },
    
    
        /**
         * This is a renderless component.
         */
        render() {
            return this.$slots.default
        },
    
        /**
         * Binds the event listener since this is a renderless component.
         */
        mounted() {
            this.$el.addEventListener('keydown', this.handler)
        }
    }
    </script>
    
  • URL: /components/raw/directional/BpDirectional.vue
  • Filesystem Path: resources/styles/utilities/directional/BpDirectional.vue
  • Size: 4.6 KB