Gaël Duval
Gaël Duval

Reputation: 75

VueJS - Click event is not triggered as parent event prevaults

I am building a search input that fetchs data from my API and lists it in a dropdown list.

Here is the behavior I want my component to have:

My issue has to do with Event Bubbling.

I can't click the elements in the dropdown list as the @focusout event from the input is being triggered first and closes the list.

import ...

export default {
  components: {
    ...
  },

  props: {
    ...
  },

  data() {
    return {
      results: [],
      activeItem: null,
      isFocus: false,
    }
  },

  watch: {
    modelValue: _.debounce(function (newSearchText) {
      ... API Call
    }, 350)
  },

  computed: {
    computedLabel() {
      return this.required ? this.label + '<span class="text-primary-600 font-bold ml-1">*</span>' : this.label;
    },

    value: {
      get() {
        return this.modelValue
      },
      set(value) {
        this.$emit('update:modelValue', value)
      }
    }
  },

  methods: {
    setActiveItem(item) {
      this.activeItem = item;

      this.$emit('selectItem', this.activeItem);
    },

    resetActiveItem() {
      this.activeItem = null;

      this.isFocus = false;
      this.results = [];

      this.$emit('selectItem', null);
    },
  },

  emits: [
    'selectItem',
    'update:modelValue',
  ],
}
</script>

<template>
  <div class="relative">
    <label
        v-if="label.length"
        class="block text-tiny font-bold tracking-wide font-medium text-black/75 mb-1 uppercase"
        v-html="computedLabel"
    ></label>

    <div :class="widthCssClass">
      <div class="relative" v-if="!activeItem">
        <div class="flex items-center text-secondary-800">
          <svg
              xmlns="http://www.w3.org/2000/svg"
              class="h-3.5 w-3.5 ml-4 absolute"
              fill="none"
              viewBox="0 0 24 24"
              stroke="currentColor"
              stroke-width="2"
          >
            <path
                stroke-linecap="round"
                stroke-linejoin="round"
                d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
            />
          </svg>

          <!-- The input that triggers the API call -->
          <input
              class="text-black py-2.5 pr-3.5 pl-10 text-black focus:ring-primary-800 focus:border-primary-800 block w-full rounded sm:text-sm border-gray-300"
              placeholder="Search for anything..."
              type="text"
              @input="$emit('update:modelValue', $event.target.value)"
              @focusin="isFocus = true"
              @focusout="isFocus = false"
          >
        </div>

       <!-- The Dropdown list -->
        <Card
            class="rounded-t-none shadow-2xl absolute w-full z-10 mt-1 overflow-y-auto max-h-48 px-0 py-0"
            v-if="isFocus && results.length"
        >
          <div class="flow-root">
            <ul role="list" class="divide-y divide-gray-200">

              <!-- API results are displayed here -->
              <li
                  v-for="(result, index) in results"
                  :key="index"
                  @click="setActiveItem(result)" <!-- The event I can't trigger -->
              >
                <div class="flex items-center space-x-4 cursor-pointer px-4 py-3">
                  <div class="flex-shrink-0">
                    <img
                        class="h-8 w-8 rounded-md ring-2 ring-lighter shadow-lg"
                        :src="result.image ?? this.$page.props.page.defaultImage.url"
                        :alt="result.title"
                    />
                  </div>
                  <div class="min-w-0 flex-1">
                    <p
                        class="truncate text-sm font-medium text-black"
                        :class="{
                          'text-primary-900 font-bold': result.id === activeItem?.id
                        }"
                    >
                      {{ result.title }}
                    </p>
                    <p class="truncate text-sm text-black/75">
                      {{ result.description }}
                    </p>
                  </div>
                  <div v-if="result.action">
                    <Link
                        :href="result.action?.url"
                        class="inline-flex items-center rounded-full border border-gray-300 bg-white px-2.5 py-0.5 text-sm font-medium leading-5 text-black/75 shadow-sm hover:bg-primary-50"
                        target="_blank"
                    >
                      {{ result.action?.text }}
                    </Link>
                  </div>
                </div>
              </li>
            </ul>
          </div>
        </Card>
      </div>

      <!-- Display the active element, can be ignored for this example -->
      <div v-else>
        <article class="bg-primary-50 border-2 border-primary-800 rounded-md">
          <div class="flex items-center space-x-4 px-4 py-3">
            <div class="flex-shrink-0">
              <img
                  class="h-8 w-8 rounded-md ring-2 ring-lighter shadow-lg"
                  :src="activeItem.image ?? this.$page.props.page.defaultImage.url"
                  :alt="activeItem.title"
              />
            </div>
            <div class="min-w-0 flex-1">
              <p class="truncate text-sm font-medium text-black font-bold">
                {{ activeItem.title }}
              </p>
              <p class="truncate text-sm text-black/75 whitespace-pre-wrap">
                {{ activeItem.description }}
              </p>
            </div>
            <div class="flex">
              <AppButton @click.stop="resetActiveItem();" @focusout.stop>
                <svg
                    class="w-5 h-5 text-primary-800"
                    fill="none"
                    stroke="currentColor"
                    viewBox="0 0 24 24"
                    xmlns="http://www.w3.org/2000/svg"
                >
                  <path
                      stroke-linecap="round"
                      stroke-linejoin="round"
                      stroke-width="2"
                      d="M6 18L18 6M6 6l12 12"
                  ></path>
                </svg>
              </AppButton>
            </div>
          </div>
        </article>
      </div>
    </div>
  </div>
</template>

Here is a look at the input:

With API results (can't click the elements):

enter image description here

When no data is found:

enter image description here

I tried:

handleFocusOut(e) {
    console.log(e.relatedTarget, e.target, e.currentTarget)
    // No matter where I click:
    // e.relatedTarget = null
    // e.target = <input id="search" class="...
    // e.currentTarget = <input id="search" class="...
}
...

<input
    id="search"
    class="..."
    placeholder="Search for anything..."
    type="text"
    @input="$emit('update:modelValue', $event.target.value)"
    @focusin="isFocus = true"
    @focusout="handleFocusOut($event)"
>

enter image description here

The solution:

relatedTarget will be null if the element you click on is not focusable. by adding the tabindex attribute it should make the element focusable and allow it to be set as relatedTarget. if you actually happen to be clicking on some container or overlay element make sure the element being clicked on has that tabindex="0" added to it so you can maintain isFocus = true

Thanks to @yoduh for the solution

Upvotes: 1

Views: 473

Answers (1)

yoduh
yoduh

Reputation: 14709

The root issue looks to be how the dropdown list is being removed from the DOM as soon as the input loses focus because of the v-if on it.

<Card
  v-if="isFocus && results.length"
>

This is ok to have, but you'll need to work around it by coming up with a solution that keeps isFocus true whether the focus is on the input or the dropdown. I would suggest your input's @focusout to execute a method that only sets isFocus = false if the focus event's relatedTarget is not any of the dropdown items (can be determined via classname or other attribute). One roadblock to implementing this is that some elements aren't natively focusable, like <li> items, so they won't be set as the relatedTarget, but you can make them focusable by adding the tabindex attribute. Putting it all together should look something like this:

<input
  type="text"
  @input="$emit('update:modelValue', $event.target.value)"
  @focusin="isFocus = true"
  @focusout="loseFocus($event)"
/>

...

<li
  v-for="(result, index) in results"
  :key="index"
  class="listResult"
  tabindex="0"
  @click="setActiveItem(result)"
>
loseFocus(event) {
  if (event.relatedTarget?.className !== 'listResult') {
    this.isFocus = false;
  }
}
setActiveItem(item) {
  this.activeItem = item;
  this.isFocus = false;
  this.$emit('selectItem', this.activeItem);
}

Upvotes: 2

Related Questions