Search code examples
cssvue.jsvue-componentblurcustom-component

Custom Dropdown input not triggering blur event


I have a custom dropup component that I am building and I find myself running into some very weird issues with it. The biggest one being the blur event, which doesnt seem to be firing when I click outside of the input.

Basically I just want the options elements to hide on blur, if there is no localValue then reset the component back to the Initial View and if there IS a localValue set it to the Selected State, keeping the value in there. I'm really not sure where I am going wrong here.

---Designs---

Initial State

enter image description here

Toggled State

enter image description here

Selected State

enter image description here

I've made a CodeSandbox so you can see this thing live.

I would love if anyone has any ideas on how I can fix this and get the blur event happening properly.

Any advice would be greatly appreciated!

Cheers!

NOTE I've tried putting the blur event on the input itself, since it makes sense I think that when clicking outside of the input, it should close everything. optionsAreVisible.value = true does work to close it, but I dont think just blurring the input is the way to go here. I think I still want to have the blur on the entire element.

Extra Issues:

  1. Is my :key="buttonKey" and buttonKey.value++ combination to reset the component the proper way to do this? It seems hacky and a bit wrong to me, but I am not totally sure!

  2. How to get the Initial State and Selected State to be the same size? The Selected State has a few more elements in it, but the design has them as the same width and I've been having trouble with getting that to work properly without hardcoding widths (which I dont want to do)

  3. Not sure if this is a bug with Codesandbox, but :class="[localValue === '' ? '_hide' : '', '_input-active-delete']" line doesnt seem to be working so I have to do a v-if, which is not what my actually codebase uses. ANy idea what could be going on there? enter image description here

SelectModelInput

<template>
  <div :key="buttonKey">
    <div
      style="position: relative; width: 100%"
      tabindex="-1"
      @blur="() => closeDropdown()"
    >
      <div class="_input" @click="() => toggleDropup()">
        <p
          v-if="selectModelVersionIsVisible"
          class="_input-active-select-model"
        >
          Select Model
        </p>
        <div v-if="!selectModelVersionIsVisible" class="_input-active">
          <p class="_input-active-op-real">OP-REAL</p>
          <input
            v-model="localValue"
            :class="[
              localValue !== '' && !optionsAreVisible ? '_bg-change' : '',
              '_input-active-input',
            ]"
            type="text"
            placeholder="Type to Search"
          />
          <button
            v-if="localValue"
            :class="[localValue === '' ? '_hide' : '', '_input-active-delete']"
            @click="() => clearLocalVal()"
          >
            <svg
              style="width: 18px"
              xmlns="http://www.w3.org/2000/svg"
              width="100%"
              height="100%"
              viewBox="0 0 24 24"
              fill="none"
              stroke="#A5B0CB"
              stroke-width="2"
              stroke-linecap="round"
              stroke-linejoin="round"
              class="feather feather-x"
            >
              <line x1="18" y1="6" x2="6" y2="18"></line>
              <line x1="6" y1="6" x2="18" y2="18"></line>
            </svg>
          </button>
        </div>
      </div>
      <Transition name="fade">
        <div
          v-if="optionsAreVisible && filteredOptions.length !== 0"
          class="_options"
        >
          <a
            v-for="option in filteredOptions"
            :key="option.model"
            class="_option"
          >
            <div class="_option-inner" @click="selectOption(option.model)">
              <p class="_option-inner-model">{{ option.model }}</p>
              <p class="_option-inner-date">{{ option.date }}</p>
            </div>
          </a>
        </div>
      </Transition>
    </div>
  </div>
</template>
<script>
import { computed, defineComponent, ref, watch } from "vue";

export default defineComponent({
  emit: ["input"],
  props: {
    value: { type: String },
    models: {
      type: Array,
      default: () => [
        { model: "Test v1", date: "Jul.5.2022" },
        { model: "Test v2", date: "Jul.6.2022" },
        { model: "Test v3", date: "Jul.8.2022" },
      ],
    },
  },
  setup(props, { emit }) {
    const localValue = ref(props.value);
    watch(localValue, (newVal) => {
      emit("input", newVal);
    });
    const buttonKey = ref(0);
    const optionsAreVisible = ref(false);
    const selectModelVersionIsVisible = ref(true);
    const options = ref(props.models);
    const filteredOptions = computed(() => {
      const filteredOptions = options.value.filter((option) =>
        option.model.toLowerCase().includes(localValue.value?.toLowerCase())
      );
      return filteredOptions;
    });
    function toggleDropup() {
      console.log("toggle Dropup");
      optionsAreVisible.value = true;
      selectModelVersionIsVisible.value = false;
    }
    function selectOption(option) {
      console.log(`option → `, option);
      localValue.value = option;
      optionsAreVisible.value = false;
      selectModelVersionIsVisible.value = false;
    }
    function closeDropdown() {
      console.log(`localValue → `, localValue.value);
      console.log("ping");
      optionsAreVisible.value = true
    }
    function clearLocalVal() {
      buttonKey.value++;
      selectModelVersionIsVisible.value = true;
      optionsAreVisible.value = false;
      localValue.value = "";
    }
    return {
      options,
      optionsAreVisible,
      selectModelVersionIsVisible,
      toggleDropup,
      filteredOptions,
      localValue,
      selectOption,
      clearLocalVal,
      buttonKey,
      closeDropdown,
    };
  },
});
</script>
<style lang="sass" scoped>
button
  background: none
  border: none
p
  padding: 0
  margin: 0
::placeholder
  font-style: italic
  line-height: 0
  color: #A5B0CB
._input
  all: unset
  position: relative
  display: flex
  justify-content: center
  align-items: center
  min-height: 2rem
  min-width: 212px
  background: #161a24
  border-radius: 1rem
  &:hover,
  &:focus
    box-shadow: 0 0 0 1px #3867D0

  &-active
    display: flex
    justify-content: center
    align-items: center
    // padding-right: 2rem
    &-select-model
      // padding: 6px 0
      color: #A5B0CB
      font-size: 1rem
      font-weight: 300
    &-op-real
      display: flex
      justify-content: center
      align-items: center
      height: 100%
      border-top-left-radius: 1rem
      border-bottom-left-radius: 1rem
      padding: 0 10px
      background: #202634
      font-size: 12px
      font-weight: bold
      letter-spacing: 0.96px
      padding: 8px 10px
      user-select: none
    &-input
      all: unset
      font-size: 14px
      text-align: center
      color: #A5B0CB
      padding: 6px 0px
      padding-right: 2rem
      flex: 1
      border-top-right-radius: 1rem
      border-bottom-right-radius: 1rem
      &:focus::placeholder
        color: transparent
    &-delete
      position: absolute
      bottom: 50%
      transform: translateY(50%)
      right: 0
      display: flex
      justify-content: center
      padding: 0
      padding-right: 8px

._bg-change
  background-color: #202634
._hide
  visibility: hidden
._options
  position: absolute
  bottom: 100%
  left: 30%
  z-index: 100
  display: flex
  flex-direction: column
  list-style-type: none
  padding: 0.5rem 1rem
  gap: 0.25rem
  background-color: #2C2B30
  border: 1px solid #727275
  border-radius: 0.5rem
._option
  background-color: transparent
  font-size: 0.875rem
  line-height: 1.25rem
  font-weight: 400
  white-space: nowrap
  width: 100%
  &-inner
    width: 100%
    display: flex
    justify-content: space-between
    padding: 0.25rem
    cursor: pointer
    &:hover
      background-color: #3582F5
      border-radius: 0.25rem
    &-model
      font-size: 14px
      color: #fff
      margin-right: 2.5rem
    &-date
      font-size: 12px
      color: #B2B4B9

.fade-enter-active,
.fade-leave-active
  transition: opacity 300ms

.fade-enter,
.fade-leave-to
  opacity: 0
</style>

App.vue

<template>
  <div class="_wrapper">
    <SelectModelInput
      v-model="text"
      :models="modelsFromDev"
    />
    <br />
    <span style="color: white">{{ text }}</span>
  </div>
</template>

<script>
import { defineComponent, ref } from "vue";
import SelectModelInput from "./components/SelectModelInput.vue";
export default defineComponent({
  setup() {
    const text = ref("");
    const modelsFromDev = [
      { model: "Test v4", date: "Jul.5.2022" },
      { model: "Test v5", date: "Jul.6.2022" },
      { model: "Test v6", date: "Jul.8.2022" },
    ];
    return { text, modelsFromDev };
  },
  components: { SelectModelInput },
});
</script>

<style lang="sass" scoped>
body
  padding: 0
  margin: 0
._wrapper
  color: white
  display: flex
  flex-direction: column
  justify-content: center
  align-items: center
  width: 100vw
  height: 100vh
  background: #283044
</style>

Solution

  • Showing the options should be tied to clicking on the "Type to Search" <input>. When this <input> loses focus then set options visible to false. Using your code I also had to make functions to separately toggle selectModelVersionIsVisible and optionsAreVisible: codesandbox

    Also regarding one of your other comments, clicking the "X" button can be made to clear localValue by just setting localValue = '' on click which is also in my sandbox. Don't need to use :key