vuejs / core

🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
https://vuejs.org/
MIT License
45.62k stars 8k forks source link

Expose patchProp logic to composition function authors #1914

Open AlexVipond opened 3 years ago

AlexVipond commented 3 years ago

What problem does this feature solve?

When you're writing composition functions, you'll inevitably find yourself in situations where you need to bind data to an element, update classes or styles, or add an event listener to an element.

Example:

In a WAI-ARIA compliant listbox/combobox, the list of options (e.g. ul element) should have an aria-activedescendant attribute whose value is the HTML id of the active list option (e.g. li element).

In turn, each option in the list should have an aria-selected attribute whose value is true or false based on whether or not they are the active descendant.

It would be great to handle this logic inside a composition function by doing the following:

  1. Creating a ref for the id of the active option
  2. Creating a ref for the list of options' DOM elements
  3. Using watchEffect to watch the id ref, and when it changes, patch the aria-activedescendant value and all the aria-selected values on the DOM elements
  4. Returning the DOM element refs from the composition function, so that end users can attach them where they need to go in the template. Code example below to illustrate the developer experience:
<template>
  <span :ref="listbox.label.ref">
    Select an option:
  </span>
  <button :ref="listbox.button.ref">
    {{ listbox.selected }}
  </button>
  <ul
    :ref="listbox.list.ref"
    v-show="listbox.list.isOpen"
  >
    <li
      v-for="({ value, isActive, isSelected }) in listbox.options.values"
      :key="value"
      :ref="listbox.options.ref"
    >
      <div :class="[
        isSelected ? 'list-option--selected' : '',
        isActive ? 'list-option--active' : '',
      ]">
        {{ value }}
      </div>
    </li>
  </ul>
</template>

<script>
import { useListbox } from 'path/to/useListbox'

export default {
  setup () {
    const listbox = useListbox({
            options: [
              'option 1',
              'option 2',
              'option 3',
            ],
            defaultOption: 'option 2',
          })

    return {
      listbox,
    }
  }      
}
</script>

Inside watchEffect, the ideal solution is to patch attributes using the same logic that Vue uses internally to support v-bind in single file components.

The problem is that there is no way to access/import that logic from Vue's API.

If I'm not mistaken, that logic is implemented in patchProp:

https://github.com/vuejs/vue-next/blob/425335c28bdb48f2f48f97021fc0a77eaa89ec34/packages/runtime-dom/src/patchProp.ts

For some projects I'm currently working on, I re-implemented that logic as additional composition functions in a package I maintain:

But I personally would prefer if Vue exposed their implementation of that logic, since it would allow composition function authors to avoid third party rewrites of code that is getting shipped in every Vue app regardless.

What does the proposed API look like?

import { ref, onMounted, watchEffect, patchProp } from 'vue'

export default function useListbox (...) {
  const activeDescendantId = ref(''),
        listElement = ref(null),
        optionsElements = ref([]),
        updateActiveDescendantId = (newId) => {
          // Update activeDescendantId per WAI-ARIA specifications
        }

  onMounted(() => {
    watchEffect(() => {
      patchProp(listElement.value, 'aria-activedescendant', activeDescendantId.value)

      optionsElements.forEach(element => {
        const isSelected = activeDescendantId.value === element.id
        patchProp(element, 'aria-selected', isSelected)
      })
    })
  })

  return {
    list: { ref: listElement },
    options: {
      // Since the options ref gets bound to a v-for, it's required to be a function ref
      // with a little extra logic inside.
      // 
      // https://v3.vuejs.org/guide/composition-api-template-refs.html#usage-inside-v-for
      ref (el) {
        optionsEls.value = [...optionsEls.value, el]
      },
    }
  }
}

For a more complete example of why it's useful to bind attributes and handle event listeners inside composition functions, see this source code for a composition-function-powered, fully WAI-ARIA compliant listbox/combobox widget.

AlexVipond commented 3 years ago

Found another great use case for patching props inside composition functions! I was recently writing a composition function inspired by Nuxt's Vue Meta to handle SEO-related reactive updates to pages' head content. Here's a small sample of its use:

useHead({
  title: computed(() => context.article.frontMatter.title),
  metas: [
    { 
      property: 'og:title',
      content: computed(() => context.article.frontMatter.title)
    },
    { 
      property: 'og:description',
      content: computed(() => context.article.frontMatter.summary ?? '')
    },
    { 
      property: 'og:image',
      content: computed(() => context.article.frontMatter.image ?? '')
    },
    { 
      property: 'og:url',
      content: computed(() => route.fullPath),
    },
  ]
})

In that use case, it would be useful to access Vue's internal methods for adding nodes (since meta and link tags need to be created on the fly, based on user input) and for patching attributes on those nodes.

Looking back at my original comment here, I'm unsure if patchProps from @vue/runtime-dom is the correct code to expose. Is there a lower level implementation that patches VNodes instead of DOM nodes? If so, it would be awesome to access that implementation inside composition functions to make sure updates are efficiently scheduled, and to make sure code is reusable across different renderers.

Here's a link to my useHead composition function in case anyone is curious how I'm hacking around this reactive SEO problem: https://github.com/baleada/vue-features/blob/main/src/features/useHead.js

And here's a full collection of DOM patching behavior that I've reimplented for use across my composition functions: https://github.com/baleada/vue-features/tree/main/src/affordances

And for a much more thorough and interesting example of how I'm patching DOM nodes from inside functions, see this composition function for a keyboard-accessible "tablist" widget with reactive ARIA roles for accessibility: