Components
Listbox
Displays a list of options, some of which can be selected.
Source Code
Copy the following code into your project.
<template>
<HListbox
as="div"
class="w-full"
:multiple="multiple"
v-model="localValue"
:disabled="disabled"
:name="name"
>
<HListboxLabel
:as="Label"
:for="inputId"
class="mb-1.5"
:class="[disabled ? 'text-muted-foreground' : '', errorMessage ? 'text-destructive' : '']"
v-if="label"
>
<slot name="label">
{{ label }} <span v-if="required" class="text-destructive">*</span>
</slot>
</HListboxLabel>
<Float
transition-name="slide"
as="div"
class="relative w-full"
placement="bottom"
:offset="8"
flip
floatingAs="template"
>
<div class="relative w-full">
<HListboxButton
class="w-full justify-start text-left hover:bg-background"
:class="[icon && 'pl-9', trailingIcon && 'pr-9']"
variant="outline"
:id="inputId"
:as="Button"
>
<slot name="selected" :selectedOption="selectedOption" :value="localValue">
<p class="truncate text-ellipsis">
{{ selectedOption }}
</p>
</slot>
</HListboxButton>
<slot :errorMessage="errorMessage" :value="localValue" name="icon">
<div v-if="icon" class="absolute inset-y-0 left-3 flex items-center justify-center">
<Icon :name="icon" class="text-muted-foreground/70" size="18" />
</div>
</slot>
<slot :errorMessage="errorMessage" :value="localValue" name="trailingIcon">
<div
v-if="trailingIcon"
class="absolute inset-y-0 right-3 flex items-center justify-center"
>
<Icon :name="trailingIcon" class="text-muted-foreground/70" size="18" />
</div>
</slot>
</div>
<HListboxOptions
class="max-h-[250px] w-full overflow-y-auto rounded-md border bg-background py-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<template v-for="(o, i) in localOptions" :key="i">
<HListboxOption
v-slot="{ selected, active }"
:value="returnObject && !optionsIsPrimitive ? o : o[valueProp]"
>
<slot name="option" :option="o" :index="i" :active="active" :selected="selected">
<li
:class="[active && 'bg-muted', selected && 'bg-primary text-primary-foreground']"
class="px-2 py-1.5 text-sm"
>
{{ o[props.labelProp] }}
</li>
</slot>
</HListboxOption>
</template>
</HListboxOptions>
</Float>
<slot :errorMessage="errorMessage" :value="localValue" name="hint">
<TransitionSlide :offset="[0, -10]">
<p class="mt-1.5 text-xs text-muted-foreground" v-if="hint && !errorMessage">
{{ hint }}
</p>
</TransitionSlide>
</slot>
<p v-if="errorMessage" class="mt-1.5 text-xs text-destructive">
<TransitionSlide :offset="[0, -10]">
<span v-if="errorMessage">
{{ errorMessage }}
</span>
</TransitionSlide>
</p>
</HListbox>
</template>
<script setup lang="ts">
import { Float } from "@headlessui-float/vue";
import Button from "@/components/UI/Button.vue";
import Label from "@/components/UI/Label.vue";
const props = withDefaults(
defineProps<{
label?: string;
name?: string;
placeholder?: string;
id?: any;
disabled?: boolean;
multiple?: boolean;
required?: boolean;
hint?: string;
errorMessage?: string;
icon?: string;
trailingIcon?: string;
options?: any[];
labelProp?: string;
valueProp?: string;
returnObject?: boolean;
modelValue?: any;
}>(),
{
placeholder: "Select an option",
options: () => [],
labelProp: "label",
valueProp: "id",
trailingIcon: "heroicons:chevron-down",
}
);
// Get the id of the input from the label or name
const inputId = computed(() => props.id || `input-${Math.random().toString(36).substring(2, 9)}`);
const emit = defineEmits<{
"update:modelValue": [any];
}>();
const localValue = computed({
get() {
return props.modelValue;
},
set(v) {
emit("update:modelValue", v);
},
});
const optionsIsPrimitive = computed(() => {
return props.options?.every(
(o) => typeof o === "string" || typeof o === "number" || typeof o === "boolean"
);
});
const localOptions = computed(() => {
if (optionsIsPrimitive.value) {
return props.options?.map((o) => ({ [props.labelProp]: o, [props.valueProp]: o }));
}
return props.options.map((o) => ({
...o,
[props.labelProp]: o[props.labelProp],
[props.valueProp]: o[props.valueProp],
}));
});
const selectedOption = computed(() => {
if (!localValue.value) return props.placeholder;
if (props.multiple) {
// If not an array, return placeholder
if (!Array.isArray(localValue.value)) return props.placeholder;
// If empty array, return placeholder
if (!localValue.value.length) return props.placeholder;
// If returnObject is true
if (props.returnObject) {
return localValue.value
.map((v: any) => {
return (
localOptions.value.find((o) => o[props.valueProp] === v[props.valueProp])?.[
props.labelProp
] || props.placeholder
);
})
.join(", ");
}
return localValue.value
.map((v: any) => {
return (
localOptions.value.find((o) => o[props.valueProp] === v)?.[props.labelProp] ||
props.placeholder
);
})
.join(", ");
}
if (props.returnObject) {
return (
localOptions.value.find((o) => o[props.valueProp] === localValue.value[props.valueProp])?.[
props.labelProp
] || props.placeholder
);
}
return (
localOptions.value.find((o) => o[props.valueProp] === localValue.value)?.[props.labelProp] ||
props.placeholder
);
});
</script>
<style scoped>
.slide-enter-active,
.slide-leave-active {
transition: all 0.2s ease-out;
}
.slide-enter-from,
.slide-leave-to {
opacity: 0;
}
</style>
Usage
Slots
Primitives
Table of contents