skip to Main Content

I am trying to create a custom select component that can accept options as HTML like this:

<MySelect v-model='value'>
  <MyOption value='1'>Option 1</MyOption>
  <MyOption value='2'>Option 2</MyOption>
</MySelect>

This is my current implementation of the custom select component:

<template>
    <div class="select">
        <div class="text" @click="visible=!visible">{{modelValue ?? text}} <IconChevronDown/></div>
        <div class="options" v-if="visible">
            <span v-for="option in options" class="p-4 hover:bg-neutral-1 cursor-pointer capitalize" @click="select(option)">{{option}}</span>
        </div>
    </div>
</template>

<script setup>
const visible = ref(false)
const props = defineProps({
    text: String,
    options: Array,
    modelValue: String
})
const emit = defineEmits(["update:modelValue"])

function select(option){
    visible.value = false;
    emit("update:modelValue", option)
}
</script>

This component accepts options as a prop and just as an array of strings. But I need more customization of options, such as adding an icon or applying styles to text. So, the ability to pass options as HTML would have solved this problem. I would appreciate if you could share your ideas of how this can be implemented!

P.S. MySelect component should not contain native select and option tags. The whole purpose of creating a custom select component is for design customization.

2

Answers


  1. Customizing list items is usually done with scoped slots:

    VUE SFC PLAYGROUND

    <script setup>
    import { ref } from 'vue'
    import MySelect from './MySelect.vue';
    
    const options = [{id: "1", title: 'Option 1'}, {id: "2", title: 'Option 2'}];
    const selected = ref();
    </script>
    
    <template>
      <my-select :options #option="{id, title, select}" v-model="selected">
        <div class="option" @click="select">id: {{ id }}, title: {{ title }}</div>
      </my-select>
    </template>
    
    <style scoped>
    .option{
      padding: 5px 10px;
      border: 1px solid gray;
      border-radius: 4px;
      cursor: pointer;
    }
    </style>
    

    MySelect.vue:

    <template>
        <div class="select">
            <div class="text" @click="visible=!visible" style="cursor:pointer">{{modelValue ? options.find(o => o.id === modelValue).title : text}}</div>
            <div class="options" v-if="visible">
                <template v-for="option in options">
                  <slot name="option" v-bind="{...option, select: () => select(option.id)}">
                    <span class="p-4 hover:bg-neutral-1 cursor-pointer capitalize" @click="select(option.id)">{{option.title}}</span>
                  </slot>
                </template>
            </div>
        </div>
    </template>
    
    <script setup>
    import {ref} from 'vue';
    const visible = ref(false)
    const props = defineProps({
        text: {type: String, default: 'Toggle dropdown'},
        options: Array,
        modelValue: String
    })
    const emit = defineEmits(["update:modelValue"])
    
    function select(option){
        visible.value = false;
        emit("update:modelValue", option)
    }
    </script>
    

    You can also automatically handle the selection click, but for that you need a custom render option slot component. Plus of cause you can use your own component to pass to the slot:

    VUE SFC PLAYGROUND

    <script setup>
    import { ref } from 'vue'
    import MySelect from './MySelect.vue';
    import MyOption from './MyOption.vue';
    
    const options = [{id: "1", title: 'Option 1'}, {id: "2", title: 'Option 2'}];
    const selected = ref();
    </script>
    
    <template>
      <my-select :options #option="{id, title}" v-model="selected">
        <my-option :value="id">{{ title }}</my-option>
      </my-select>
    </template>
    

    MySelect.vue

    <template>
        <div class="select">
            <div class="text" @click="visible=!visible" style="cursor:pointer">{{modelValue ? options.find(o => o.id === modelValue).title : text}}</div>
            <div class="options" v-if="visible">
                <template v-for="option in options">
                  <render-option v-if="$slots.option" v-bind="option" @click="select(option.id)"/>
                  <span v-else class="p-4 hover:bg-neutral-1 cursor-pointer capitalize" @click="select(option.id)">{{option.title}}</span>
                </template>
            </div>
        </div>
    </template>
    
    <script setup>
    import {ref, useSlots, mergeProps} from 'vue';
    const visible = ref(false)
    const props = defineProps({
        text: {type: String, default: 'Toggle dropdown'},
        options: Array,
        modelValue: String
    })
    const emit = defineEmits(["update:modelValue"])
    
    const $slots = useSlots();
    const renderOption = props => $slots.option(props)
        .map(vnode => (Object.assign(vnode.props ??= {}, {onClick: () => select(props.id)}, vnode.props ?? {}), vnode));
    
    function select(option){
    
        visible.value = false;
        emit("update:modelValue", option)
    }
    </script>
    

    MyOption.vue

    <script setup>
    
    </script>
    
    <template>
      <div class="option">
        <slot></slot>
      </div>
    </template>
    <style scoped>
    .option{
      padding: 5px 10px;
      border: 1px solid gray;
      border-radius: 4px;
      cursor: pointer;
    }
    </style>
    
    Login or Signup to reply.
  2. You can create a custom slot render function and collect default slots of the slotted child components:

    VUE SFC PLAYGROUND

    Usually I would prefer the scoped slot solution posted in my other answer here, BUT nobody stops from MERGING 2 solutions into 1: using the default slot from here and #option from the other answer. So the select component could be fed by both slots.

    <script setup>
    import { ref } from 'vue'
    import MySelect from './MySelect.vue';
    import MyOption from './MyOption.vue';
    
    const selected = ref();
    </script>
    
    <template>
      <my-select v-model="selected">
        <my-option value="1"><span style="color:red">Red option</span></my-option>
        <my-option value="2"><span style="color:blue">Blue option</span></my-option>
      </my-select>
    </template>
    

    MySelect.vue

    <template>
        <div class="select">
            <div class="text" @click="visible=!visible" style="cursor:pointer">
                <component v-if="modelValue" :is="options[modelValue]"/>
                <template v-else>{{ text }}</template>
            </div>
            <div class="options" v-if="visible">
                <render-options></render-options>
            </div>
        </div>
    </template>
    
    <script setup>
    import {ref, useSlots} from 'vue';
    const visible = ref(false)
    const props = defineProps({
        text: {type: String, default: 'Toggle dropdown'},
        options: Array,
        modelValue: String
    })
    const options = ref({});
    const emit = defineEmits(["update:modelValue"])
    
    const $slots = useSlots();
    const renderOptions = () => {
        options.value = {};
        return $slots.default()
        .map(vnode => {
            // collection options' default slot
            options.value[vnode.props.value] = vnode.children.default;
            Object.assign(vnode.props ??= {}, {onClick: () => select(vnode.props.value)}, vnode.props ?? {});
            return vnode;
        });
    }
    
    function select(option){
        visible.value = false;
        emit("update:modelValue", option)
    }
    </script>
    

    MyOption.vue

    <script setup>
    
    </script>
    
    <template>
      <div class="option">
        <slot></slot>
      </div>
    </template>
    <style scoped>
    .option{
      padding: 5px 10px;
      border: 1px solid gray;
      border-radius: 4px;
      cursor: pointer;
    }
    </style>
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search