2
votes

I'm currently building an app using the Vue framework and came across a strange issue that I was unable to find a great solution for so far:

What I'm trying to do is add a class to a parent container in case a specific element inside the container (input, select, textarea etc.) gets focus. Here's the example code:

  <div class="form-group placeholder-label">
    <label for="desc"><span>Description</span></label>
    <div class="input">
      <input id="desc" type="text" />
    </div>
  </div>

In Vanilla JS of course, this is easily done:

const parent = document.querySelector('.placeholder-label');
const input = parent.querySelector('input');
input.addEventListener('focus', (e) => {
  parent.classList.add('active');
});

In the same way, you could loop through all .placeholder-label elements and add the event to their child inputs/selects etc. to add this basic functionality. There are two moving parts here:

  1. You don't know the type of the parent element, just that it has .placeholder-label on it.
  2. You don't know the type of the child element, just that it is some sort of HTML form element inside the parent element.

Can I build a Vue component that toggles a class on a given parent element based on focus/blur of a given child element? The best I could come up with is use slots for the child elements, but then I still need to build a component for each parent. Even when using mixins for the reused parts it's still quite a mess compared to the five lines of code I need to write in pure JS.

My template:

<template>
  <div
    class="form-group"
    :class="{ 'active': active }"
  >
    <label :for="inputID"><span>{{ inputLabel }}</span></label>
    <slot
      name="input"
      :focusFunc="makeActive"
      :blurFunc="makeInactive"
      :inputLabel="inputLabel"
      :inputID="inputID"
    />
  </div>
</template>

<script>
export default {
  name: 'TestInput',
  props: {
    inputLabel: {
      type: String,
      default: '',
    },
    inputID: {
      type: String,
      required: true,
    },
  },
  // data could be put into a mixin
  data() {
    return {
      active: false,
    };
  },
  // methods could be put into a mixin
  methods: {
    makeActive() {
      this.active = true;
    },
    makeInactive() {
      this.active = false;
    },
  },
};
</script>

Usage:

<test-input
  :input-i-d="'input-2'"
  :input-label="'Description'"
>
  <template v-slot:input="scopeVars">
    <!-- this is a bootstrap vue input component -->
    <b-form-input
      :id="scopeVars.inputID"
      :state="false"
      :placeholder="scopeVars.inputLabel"
      @blur="scopeVars.blurFunc"
      @focus="scopeVars.focusFunc"
    />
  </template>
</test-input>

I guess I'm simply missing something or is this a problem that Vue just can't solve elegantly?

Edit: In case you're looking for an approach to bubble events, here you go. I don't think this works with slots however, which is necessary to solve my issue with components.

2
Have the child component emit an event and parent catch that event?Chris Li
Possible duplicate of Vuejs - bubbling custom eventsbernie
Don't know if could be helpful but there is a css pseudo-selector that selects an element if that element contains any children that have :focus. :focus-whitinDavide Castellini
@bernie Thanks! The solution seems to work if you know what components you'll be using beforehand. Working with slots and bubbling events seems to be a lot trickier.j0Shi
@DavideCastellini Thanks. That seems to work fine on newer browsers and there's a polyfill available: npmjs.com/package/focus-within-polyfillj0Shi

2 Answers

1
votes

For those wondering here are two solutions. Seems like I did overthink the issue a bit with slots and everything. Initially I felt like building a component for a given element that receives a class based on a given child element's focus was a bit too much. Turns out it indeed is and you can easily solve this within the template or css.

  1. CSS: Thanks to @Davide Castellini for bringing up the :focus-within pseudo-selector. I haven't heard of that one before. It works on newer browsers and has a polyfill available.
  2. TEMPLATE I wrote a small custom directive that can be applied to the child element and handles everything.

Usage:

v-toggle-parent-class="{ selector: '.placeholder-label', className: 'active' }"

Directive:

directives: {
  toggleParentClass: {
    inserted(el, { value }) {
      const parent = el.closest(value.selector);
      if (parent !== null) {
        el.addEventListener('focus', () => {
          parent.classList.add(value.className);
        });
        el.addEventListener('blur', () => {
          parent.classList.remove(value.className);
        });
      }
    },
  },
},
0
votes

try using $emit

child:

<input v-on:keyup="emitToParent" />

-------

    methods: {
      emitToParent (event) {
        this.$emit('childToParent', this.childMessage)
      }
    }

Parent:

<child v-on:childToParent="onChildClick">

--------

methods: {
    // Triggered when `childToParent` event is emitted by the child.
    onChildClick (value) {
      this.fromChild = value
    }
  }

use this pattern to set a property that you use to change the class hope this helps. let me know if I misunderstood or need to better explain!