Skip to content

EpMultiSearch

WARNING

This component needs to be updated to use CSS custom properties instead of props.

Props

NameDescriptionTypeDefault
placeholder-string''
icon-object{}
disabled-booleanfalse
autofocus-booleanfalse
width-string'100%'
height-string'5rem'
borderWidth-string'0.1rem'
borderStyle-string'solid'
borderColor-string'var(--border-color)'
borderRadius-string'var(--border-radius)'
backgroundColor-string'var(--interface-foreground)'
color-string'var(--text-color)'

Events

NameDescriptionPayload
input--
focus--
esc--
blur--
enter--
clear--
query-close--
delete--

INFO

This component does not use slots.

Component Code

vue
<template>
  <div
    :class="['ep-multi-search', classes]"
    :style="inputStyles"
  >
    <div
      v-if="icon"
      class="ep-multi-search__icon"
      :style="iconStyles"
    >
      <Search01 />
    </div>
    <div class="queries">
      <div
        v-for="(item, index) in query"
        :key="index"
        :class="['query', { 'query--operator': isOperator(item) }]"
        @click="onQueryClose(item, index)"
      >
        <span class="query__text font-size--small">{{ item }}</span>
        <Cancel01 />
      </div>
    </div>
    <input
      ref="input"
      v-model="value"
      type="text"
      :placeholder="placeholderValue"
      :disabled="disabled"
      @input="onInput"
      @focus="onFocus"
      @blur="onBlur"
      @keydown.enter="onEnter"
      @keydown.delete="onDelete"
      @keydown.esc="onEsc"
    >
    <div
      v-if="clearable"
      class="ep-multi-search__clear"
      :style="iconStyles"
      @click="onClear"
    >
      <Cancel01 />
    </div>
  </div>
</template>

<script setup>
  import Cancel01 from '@ericpitcock/epicenter-icons/icons/Cancel01'
  import Search01 from '@ericpitcock/epicenter-icons/icons/Search01'
  import { computed, ref, watch } from 'vue'

  defineOptions({
    name: 'EpMultiSearch',
  })

  const props = defineProps({
    placeholder: {
      type: String,
      default: ''
    },
    icon: {
      type: Object,
      default: () => ({})
    },
    disabled: {
      type: Boolean,
      default: false
    },
    autofocus: {
      type: Boolean,
      default: false
    },
    width: {
      type: String,
      default: '100%'
    },
    height: {
      type: String,
      default: '5rem'
    },
    borderWidth: {
      type: String,
      default: '0.1rem'
    },
    borderStyle: {
      type: String,
      default: 'solid'
    },
    borderColor: {
      type: String,
      default: 'var(--border-color)'
    },
    borderRadius: {
      type: String,
      default: 'var(--border-radius)'
    },
    backgroundColor: {
      type: String,
      default: 'var(--interface-foreground)'
    },
    color: {
      type: String,
      default: 'var(--text-color)'
    }
  })

  const emit = defineEmits(['input', 'focus', 'esc', 'blur', 'enter', 'clear', 'query-close', 'delete'])

  const input = ref(null)
  const hasFocus = ref(false)
  const value = ref('')
  const query = ref([])

  const classes = computed(() => ({
    'ep-multi-search--has-icon': props.icon,
    'ep-multi-search--focus': hasFocus.value,
    'ep-multi-search--disabled': props.disabled,
  }))

  const clearable = computed(() => query.value.length > 0 || value.value.length > 0)

  const iconStyles = computed(() => ({
    flex: `0 0 ${props.height}`,
    height: props.height,
  }))

  const inputStyles = computed(() => ({
    width: props.width,
    height: props.height,
    borderRadius: props.borderRadius,
    backgroundColor: props.backgroundColor,
    color: props.color
  }))

  const placeholderValue = computed(() => {
    return value.value === '' && query.value.length === 0 ? props.placeholder : '+ Add to your search'
  })

  watch(query, () => {
    console.log('query', query.value)
  })

  const isOperator = (term) => term === 'AND' || term === 'OR'

  const onQueryClose = (item, index) => {
    query.value.splice(index, 1)
    emit('query-close', parseQuery(query.value))
  }

  const onInput = (event) => {
    emit('input', event.target.value)
  }

  const onEsc = () => {
    input.value.blur()
    emit('esc', parseQuery(query.value))
  }

  const onFocus = () => {
    hasFocus.value = true
    emit('focus', parseQuery(query.value))
  }

  const onBlur = () => {
    hasFocus.value = false
    emit('blur', parseQuery(query.value))
  }

  const onEnter = () => {
    const trimmedValue = value.value.trim()
    if (!trimmedValue) return

    const lastQueryItem = query.value[query.value.length - 1]

    if (isOperator(trimmedValue)) {
      // Prevent consecutive operators (e.g., "AND AND")
      if (!query.value.length || isOperator(lastQueryItem)) return
    }

    query.value.push(trimmedValue)
    emit('enter', parseQuery(query.value))
    value.value = ''
  }

  const onDelete = () => {
    if (value.value === '') {
      query.value.pop()
      emit('delete', parseQuery(query.value))
    }
  }

  const onClear = () => {
    query.value = []
    value.value = ''
    input.value.focus()
    emit('clear', parseQuery(query.value))
  }

  /**
   * Parses the query, ensuring AND/OR logic is correctly handled
   */
  const parseQuery = (queries) => {
    const andQueries = []
    const orQueries = []
    let currentOperator = 'OR' // Default behavior is OR unless AND is explicitly added

    queries.forEach(q => {
      if (isOperator(q)) {
        currentOperator = q
      } else {
        if (currentOperator === 'AND') {
          andQueries.push(q)
        } else {
          orQueries.push(q) // Default is OR
        }
      }
    })

    return { and: andQueries, or: orQueries }
  }
</script>

<style lang="scss" scoped>
  .ep-multi-search {
    display: flex;
    align-items: center;
    border: 1px solid var(--border-color);
    border-radius: var(--border-radius);
    padding: 0.5rem;
    gap: 0.5rem;
  }

  .queries {
    display: flex;
    flex-wrap: wrap;
    gap: 0.5rem;
  }

  .query {
    display: flex;
    align-items: center;
    background-color: var(--interface-surface);
    border: 1px solid var(--border-color);
    padding: 0.3rem 0.6rem;
    border-radius: var(--border-radius);
    font-size: 0.9rem;
  }

  .query--operator {
    background-color: var(--highlight-color, #ffcc00);
    color: black;
    font-weight: bold;
  }
</style>

Styles (SCSS)

scss
@use '../mixins/_mixins' as *;

.ep-multi-search {
  display: flex;
  flex-flow: row nowrap;
  align-items: center;
  overflow: hidden;
  border-width: 1px;
  border-style: solid;
  border-color: var(--border-color);

  &:focus-within {
    outline: var(--ep-default-focus-outline);
    outline-offset: var(--ep-focus-outline-offset);
  }

  .queries {
    display: flex;
    flex-flow: row wrap;
    align-items: center;
    gap: 0.3rem;
    padding-right: 0.6rem;

    .query {
      display: flex;
      flex-flow: row nowrap;
      align-items: center;
      gap: 0.3rem;
      height: 100%;
      padding: 0 0.6rem 0 1rem;
      background-color: var(--primary-color-base);
      border-radius: var(--border-radius);
      color: hsl(var(--gray-0));

      @include hover {
        &:hover {
          background-color: var(--primary-color-600);
          cursor: pointer;
        }
      }

      .query__text {
        line-height: 2rem;
      }

      .query__close {
        // background-color: red;
        // margin-left: 0.8rem;
        cursor: pointer;
      }
    }
  }

  input {
    flex: 1;
    height: 100%;
    padding: 0 1.2rem;
    caret-color: var(--primary-color-base);

    &::placeholder {
      color: var(--text-color);
    }

    &:focus-visible {
      outline: none;

      &::placeholder {
        color: transparent;
      }
    }
  }

  &--has-icon input {
    padding-left: 0;
  }

  &--focus {
    border-color: var(--primary-color-base);
  }

  &--disabled {
    color: var(--text-color--disabled);
    border-color: var(--border-color--disabled);

    input::placeholder {
      opacity: 0.3;
    }
  }

  &__icon,
  &__clear {
    display: flex;
    justify-content: center;
    align-items: center;
  }

  &__clear {
    cursor: pointer;
  }
}