migrate select/multiselect components to Radix Vue primitives

This commit is contained in:
Gregor Vostrak
2026-02-02 00:56:06 +01:00
parent 44bcce97cf
commit bca1e8b3b5
24 changed files with 615 additions and 930 deletions

View File

@@ -1,7 +1,11 @@
<script setup lang="ts">
import SelectDropdown from '@/packages/ui/src/Input/SelectDropdown.vue';
import Badge from '@/packages/ui/src/Badge.vue';
import { ChevronDownIcon } from '@heroicons/vue/20/solid';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import type { BillableKey } from '@/types/projects';
const model = defineModel<BillableKey>({
@@ -21,38 +25,26 @@ const options: Option[] = [
},
];
function getKeyFromItem(item: Option) {
return item.key;
}
function getNameFromItem(item: Option) {
return item.name;
}
function getNameForKey(key: BillableKey | undefined) {
const item = options.find((item) => getKeyFromItem(item) === key);
const item = options.find((item) => item.key === key);
if (item) {
return getNameFromItem(item);
return item.name;
}
return '';
}
</script>
<template>
<SelectDropdown
v-model="model"
:get-key-from-item="getKeyFromItem"
:get-name-for-item="getNameFromItem"
:items="options">
<template #trigger>
<Badge size="xlarge" class="bg-input-background cursor-pointer">
<span>
{{ getNameForKey(model) }}
</span>
<ChevronDownIcon class="text-text-secondary w-5"></ChevronDownIcon>
</Badge>
</template>
</SelectDropdown>
<Select v-model="model">
<SelectTrigger>
<SelectValue>{{ getNameForKey(model) }}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem v-for="option in options" :key="option.key" :value="option.key">
{{ option.name }}
</SelectItem>
</SelectContent>
</Select>
</template>
<style scoped></style>

View File

@@ -1,11 +1,20 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { computed, nextTick, ref, watch } from 'vue';
import { useMembersQuery } from '@/utils/useMembersQuery';
import { UserIcon, ChevronDownIcon } from '@heroicons/vue/24/solid';
import { useFocus } from '@vueuse/core';
import { UserIcon } from '@heroicons/vue/24/solid';
import { ChevronDown } from 'lucide-vue-next';
import type { ProjectMember } from '@/packages/api/src';
import { Badge, SelectDropdown } from '@/packages/ui/src';
import type { Member } from '@/packages/api/src';
import {
ComboboxAnchor,
ComboboxContent,
ComboboxInput,
ComboboxItem,
ComboboxRoot,
ComboboxViewport,
} from 'radix-vue';
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import { Button } from '@/Components/ui/button';
const { members } = useMembersQuery();
@@ -24,16 +33,24 @@ const props = withDefaults(
}
);
const searchInput = ref<HTMLInputElement | null>(null);
const open = ref(false);
const searchValue = ref('');
const searchInput = ref<HTMLElement | null>(null);
useFocus(searchInput, { initialValue: true });
watch(open, (isOpen) => {
if (isOpen) {
searchValue.value = '';
nextTick(() => {
// @ts-expect-error We need to access the actual HTML Element to focus
searchInput.value?.$el?.focus();
});
}
});
const filteredMembers = computed<Member[]>(() => {
return members.value.filter((member) => {
return (
member.name.toLowerCase().includes(searchValue.value?.toLowerCase()?.trim() || '') &&
member.name.toLowerCase().includes(searchValue.value.toLowerCase().trim() || '') &&
!props.hiddenMembers.some((hiddenMember) => hiddenMember.member_id === member.id) &&
member.is_placeholder === false
);
@@ -44,29 +61,65 @@ const currentValue = computed(() => {
if (model.value) {
return members.value.find((member) => member.id === model.value)?.name;
}
return searchValue.value;
return '';
});
function selectMember(member: Member) {
model.value = member.id;
open.value = false;
}
</script>
<template>
<SelectDropdown
v-model="model"
:items="filteredMembers"
:get-key-from-item="(member) => member.id"
:get-name-for-item="(member) => member.name">
<Dropdown v-model="open" align="start" :close-on-content-click="false">
<template #trigger>
<Badge
tag="button"
class="flex w-full text-base text-left space-x-3 px-3 text-text-secondary bg-input-background font-normal cursor py-1.5">
<UserIcon class="relative z-10 w-4 text-text-secondary"></UserIcon>
<div v-if="currentValue" class="flex-1 truncate">
{{ currentValue }}
<Button
:disabled="disabled"
type="button"
variant="input"
size="input"
class="w-full justify-between text-start font-normal">
<div class="flex items-center gap-3 truncate">
<UserIcon class="w-4 text-text-secondary shrink-0" />
<span v-if="currentValue" class="truncate text-text-primary">{{
currentValue
}}</span>
<span v-else class="text-muted-foreground">Select a member...</span>
</div>
<div v-else class="flex-1">Select a member...</div>
<ChevronDownIcon class="w-4 text-text-secondary"></ChevronDownIcon>
</Badge>
<ChevronDown class="w-4 h-4 text-icon-default shrink-0" />
</Button>
</template>
</SelectDropdown>
<template #content>
<ComboboxRoot
v-model:search-term="searchValue"
:open="open"
class="relative"
:filter-function="(val: string[]) => val">
<ComboboxAnchor>
<ComboboxInput
ref="searchInput"
class="bg-card-background border-0 placeholder-text-tertiary text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
placeholder="Search for a member..." />
</ComboboxAnchor>
<ComboboxContent
:dismiss-able="false"
position="inline"
class="w-60 max-h-60 overflow-y-auto">
<ComboboxViewport>
<ComboboxItem
v-for="member in filteredMembers"
:key="member.id"
:value="member.id"
class="flex items-center gap-3 px-3 py-2.5 text-sm text-text-primary data-[highlighted]:bg-card-background-active cursor-default"
@select.prevent="selectMember(member)">
<UserIcon class="w-4 text-text-secondary shrink-0" />
<span class="truncate">{{ member.name }}</span>
</ComboboxItem>
</ComboboxViewport>
</ComboboxContent>
</ComboboxRoot>
</template>
</Dropdown>
</template>
<style scoped></style>

View File

@@ -7,9 +7,10 @@ import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import MemberCombobox from '@/Components/Common/Member/MemberCombobox.vue';
import { UserIcon, ArrowRightIcon } from '@heroicons/vue/24/solid';
import { Badge } from '@/packages/ui/src';
import { useMutation } from '@tanstack/vue-query';
import { useMutation, useQueryClient } from '@tanstack/vue-query';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification';
const queryClient = useQueryClient();
const { handleApiRequestNotifications, addNotification } = useNotificationsStore();
const show = defineModel('show', { default: false });
@@ -50,6 +51,7 @@ async function submit() {
'Members successfully merged!',
'There was an error merging the members.',
() => {
queryClient.invalidateQueries({ queryKey: ['members'] });
show.value = false;
}
);

View File

@@ -12,6 +12,10 @@ function getKeyFromItem(item: Member) {
function getNameForItem(item: Member) {
return item.name;
}
const emit = defineEmits<{
submit: [];
}>();
</script>
<template>
@@ -19,7 +23,8 @@ function getNameForItem(item: Member) {
search-placeholder="Search for a Member..."
:items="members"
:get-key-from-item="getKeyFromItem"
:get-name-for-item="getNameForItem">
:get-name-for-item="getNameForItem"
@submit="emit('submit')">
<template #trigger>
<slot name="trigger"></slot>
</template>

View File

@@ -1,7 +1,11 @@
<script setup lang="ts">
import SelectDropdown from '@/packages/ui/src/Input/SelectDropdown.vue';
import Badge from '@/packages/ui/src/Badge.vue';
import { ChevronDownIcon } from '@heroicons/vue/20/solid';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import type { Role } from '@/types/jetstream';
import { usePage } from '@inertiajs/vue3';
@@ -13,38 +17,26 @@ const page = usePage<{
availableRoles: Role[];
}>();
function getKeyFromItem(item: Role) {
return item.key;
}
function getNameFromItem(item: Role) {
return item.name;
}
function getNameForKey(key: string | undefined) {
const item = page.props.availableRoles.find((item) => getKeyFromItem(item) === key);
const item = page.props.availableRoles.find((item) => item.key === key);
if (item) {
return getNameFromItem(item);
return item.name;
}
return '';
}
</script>
<template>
<SelectDropdown
v-model="model"
:get-key-from-item="getKeyFromItem"
:get-name-for-item="getNameFromItem"
:items="page.props.availableRoles">
<template #trigger>
<Badge size="xlarge" class="bg-input-background cursor-pointer">
<span>
{{ getNameForKey(model) }}
</span>
<ChevronDownIcon class="text-text-secondary w-5"></ChevronDownIcon>
</Badge>
</template>
</SelectDropdown>
<Select v-model="model">
<SelectTrigger>
<SelectValue>{{ getNameForKey(model) }}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem v-for="role in page.props.availableRoles" :key="role.key" :value="role.key">
{{ role.name }}
</SelectItem>
</SelectContent>
</Select>
</template>
<style scoped></style>