Skip to main content
Glama
SelectMenu.vue9.98 kB
<template> <Listbox v-model="selectedOptions" :disabled="disabledBySelfOrParent" as="div" > <div class="relative"> <ListboxButton class="cursor-default relative w-full rounded-[0.1875rem] border border-neutral-300 bg-shade-0 py-1.5 pl-3 pr-10 text-left text-neutral-900 shadow-sm hover:border-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-1 disabled:opacity-50 dark:border-neutral-600 dark:bg-neutral-900 dark:text-neutral-50" > <span class="block truncate text-sm">{{ selectedLabel }}</span> <span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2" > <Icon name="selector" class="h-5 w-5 rounded-[0.1875rem] bg-neutral-300 text-shade-0" /> </span> </ListboxButton> <transition leaveActiveClass="transition ease-in duration-100" leaveFromClass="opacity-100" leaveToClass="opacity-0" > <ListboxOptions class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-shade-0 py-1 shadow-lg ring-1 ring-black ring-opacity-5 type-regular-xs focus:outline-none dark:bg-neutral-900" > <div v-if="canFilter" :class="clsx('filter-container', `--theme-${theme}`)" > <input v-model="filterString" class="filter-string" name="filterString" type="text" placeholder="Filter options" /> </div> <template v-if="Array.isArray(filteredOptions)"> <ListboxOption v-for="option in filteredOptions" :key="`${option.value}`" v-slot="{ active, selected }" :value="option" as="template" > <li :class="[ active ? 'bg-action-500 text-neutral-50' : 'text-neutral-900 dark:text-neutral-50', 'cursor-default relative select-none py-2 pl-3 pr-9', ]" > <span :class="[ isSelected(option, selected) ? 'font-semibold' : 'font-normal', 'block truncate', ]" > {{ option.label }} </span> <span v-if="isSelected(option, selected)" :class="[ active ? 'text-white' : 'text-action-500', 'absolute inset-y-0 right-0 flex items-center pr-4', ]" > <Icon name="check" /> </span> </li> </ListboxOption> </template> <template v-if=" filteredGroupOptions && Object.keys(filteredGroupOptions).length > 0 " > <ul v-for="[groupLabel, groupOptions] in Object.entries( filteredGroupOptions, )" :key="groupLabel" class="pl-3 py-2" > <span class="uppercase text-neutral-400"> {{ groupLabel }} </span> <ListboxOption v-for="option in groupOptions" :key="`${option.value}`" v-slot="{ active, selected }" :value="option" as="template" > <li :class="[ active ? 'bg-action-500 text-neutral-50' : 'text-neutral-900 dark:text-neutral-50', 'cursor-default relative select-none py-2 pl-3 pr-9', ]" > <span :class="[ isSelected(option, selected) ? 'font-semibold' : 'font-normal', 'block truncate', ]" > {{ option.label }} </span> <span v-if="isSelected(option, selected)" :class="[ active ? 'text-white' : 'text-action-500', 'absolute inset-y-0 right-0 flex items-center pr-4', ]" > <Icon name="check" /> </span> </li> </ListboxOption> </ul> </template> </ListboxOptions> </transition> </div> </Listbox> </template> <script lang="ts" setup> import { computed, toRefs, ref } from "vue"; import { Listbox, ListboxButton, ListboxOption, ListboxOptions, } from "@headlessui/vue"; import { Icon, useDisabledBySelfOrParent, useTheme, } from "@si/vue-lib/design-system"; import clsx from "clsx"; export interface Option { label: string; value: string | number | object; } export type GroupedOptions = Record<string, Option[]>; export interface StringOption extends Option { value: string; } const emit = defineEmits(["update:modelValue", "change"]); const props = defineProps<{ options: Option[] | GroupedOptions; modelValue: Option | Option[]; // to make this a multiselect, just pass in an array of Option here noneSelectedLabel?: string; // this is only valid in the multiple select case disabled?: boolean; canFilter?: boolean; }>(); const { disabled } = toRefs(props); const { theme } = useTheme(); const filterString = ref(""); const filteredOptions = computed(() => { if (!Array.isArray(props.options)) return []; if (!filterString.value) return props.options; return props.options.filter((o) => o.label.includes(filterString.value)); }); const filteredGroupOptions = computed(() => { if (Array.isArray(props.options)) return {}; if (!filterString.value) return props.options; const filtered = {} as GroupedOptions; const grouped = props.options as GroupedOptions; Object.keys(grouped).forEach((key) => { const options = grouped[key]?.filter((o) => o.label.includes(filterString.value), ); if (options && options.length > 0) filtered[key] = options; }); return filtered; }); const disabledBySelfOrParent = useDisabledBySelfOrParent(disabled); const isSelected = (option: Option, selected: boolean) => selected || ("length" in props.modelValue && props.modelValue.includes(option)); const toggleSelection = (selection: Option) => { if (!("length" in props.modelValue)) { return []; } if (props.modelValue.includes(selection)) { return props.modelValue.filter((option) => option !== selection); } else { return props.modelValue.concat([selection]); } }; const selectedOptions = computed<Option | Option[]>({ get() { return props.modelValue; }, set(value) { if ("value" in props.modelValue && "value" in value) { emit("update:modelValue", value.value === "" ? null : value); } else if ("length" in props.modelValue && "value" in value) { emit("update:modelValue", toggleSelection(value as Option)); } else { // should not be hit, but just in case emit("update:modelValue", value); } emit("change", value); }, }); const selectedLabel = computed<string>(() => { if ("length" in selectedOptions.value) { switch (selectedOptions.value.length) { case 0: return props.noneSelectedLabel ?? "select an option..."; case 1: return selectedOptions.value[0]?.label ?? "label missing"; default: return `${selectedOptions.value[0]?.label} (+${ selectedOptions.value.length - 1 })`; } } return selectedOptions.value.label; }); </script> <style lang="less" scoped> @vertical-gap: 8px; .filter-container { --text-color: @colors-black; --text-color-error: @colors-destructive-600; --text-color-muted: @colors-neutral-500; --border-color: @colors-neutral-300; --bg-color: @colors-white; color: var(--text-color); &.--theme-dark { --text-color: @colors-white; --border-color: @colors-neutral-600; --bg-color: @colors-black; } &.--error { --text-color: @colors-destructive-600; --border-color: @colors-destructive-500; } &.--focused { // --border-color: @colors-action-500; input { box-shadow: none; outline: 2px solid @colors-action-500; outline-offset: -2px; } } &.--disabled { --text-color: @colors-neutral-500; --text-color-muted: @colors-neutral-400; --bg-color: @colors-neutral-100; &.--theme-dark { --text-color: @colors-neutral-400; --text-color-muted: @colors-neutral-500; --bg-color: @colors-neutral-900; } input { cursor: not-allowed; color: currentColor; } } } // this class is on whatever the input is, whether its input, textarea, select, etc input.filter-string { width: 100%; border: 1px solid var(--border-color); border-radius: 3px; transition: border-color 0.15s; padding: 4px 12px; color: var(--text-color); font: inherit; background-color: var(--bg-color); padding: 2px 10px; height: 32px; &:hover { --border-color: @colors-neutral-500; } // set font size for our inputs // input[type='text']&, // input[type='number']&, // input[type='password']&, input { line-height: 1rem; font-size: 14px; // if font-size is at least 16 on mobile, ios will not automatically zoom in @media @mq-mobile-only { font-size: 16px; } } &::placeholder { color: var(--text-color-muted); font-style: italic; } // &:focus { // border-color: @border-color--focus; // } // &:focus { // // we have a custom focus style instead // outline: none; // } } </style>

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/systeminit/si'

If you have feedback or need assistance with the MCP directory API, please join our Discord server