11<script setup lang="ts">
22import { PlusCircleIcon } from ' @heroicons/vue/20/solid' ;
3- import Dropdown from ' @/packages/ui/src/Input/Dropdown.vue' ;
4- import { type Component , computed , nextTick , ref , watch } from ' vue' ;
3+ import { computed , nextTick , ref , watch } from ' vue' ;
54import ClientDropdownItem from ' @/packages/ui/src/Client/ClientDropdownItem.vue' ;
65import type { CreateClientBody , Client } from ' @/packages/api/src' ;
6+ import {
7+ ComboboxAnchor ,
8+ ComboboxContent ,
9+ ComboboxInput ,
10+ ComboboxItem ,
11+ ComboboxRoot ,
12+ ComboboxViewport ,
13+ } from ' radix-vue' ;
14+ import { UseFocusTrap } from ' @vueuse/integrations/useFocusTrap/component' ;
15+ import Dropdown from ' @/packages/ui/src/Input/Dropdown.vue' ;
716
817const model = defineModel <string | null >({
918 default: null ,
@@ -14,10 +23,8 @@ const props = defineProps<{
1423 createClient: (client : CreateClientBody ) => Promise <Client | undefined >;
1524}>();
1625
17- const searchInput = ref <HTMLInputElement | null >(null );
26+ const searchInput = ref <HTMLElement | null >(null );
1827const open = ref (false );
19- const dropdownViewport = ref <Component | null >(null );
20-
2128const searchValue = ref (' ' );
2229
2330function isClientSelected(id : string ) {
@@ -27,7 +34,8 @@ function isClientSelected(id: string) {
2734watch (open , (isOpen ) => {
2835 if (isOpen ) {
2936 nextTick (() => {
30- searchInput .value ?.focus ();
37+ // @ts-expect-error We need to access the actual HTML Element to focus as radix-vue does not support any other way right now
38+ searchInput .value ?.$el ?.focus ();
3139 });
3240 }
3341});
@@ -48,132 +56,89 @@ async function addClientIfNoneExists() {
4856 if (newClient ) {
4957 model .value = newClient .id ;
5058 searchValue .value = ' ' ;
51- }
52- } else {
53- if (highlightedItemId .value ) {
54- model .value = highlightedItemId .value ;
59+ open .value = false ;
5560 }
5661 }
5762}
5863
59- watch (filteredClients , () => {
60- if (filteredClients .value .length > 0 ) {
61- highlightedItemId .value = filteredClients .value [0 ].id ;
62- }
63- });
64-
65- function updateSearchValue(event : Event ) {
66- const newInput = (event .target as HTMLInputElement ).value ;
67- if (newInput === ' ' ) {
68- searchValue .value = ' ' ;
69- const highlightedClientId = highlightedItemId .value ;
70- if (highlightedClientId ) {
71- const highlightedClient = props .clients .find (
72- (client ) => client .id === highlightedClientId
73- );
74- if (highlightedClient ) {
75- model .value = highlightedClient .id ;
76- }
64+ const currentClient = computed (() => {
65+ return (
66+ props .clients .find ((client ) => client .id === model .value ) ?? {
67+ id : null ,
68+ name : ' No Client' ,
7769 }
78- } else {
79- searchValue .value = newInput ;
80- }
81- }
70+ );
71+ });
8272
8373const emit = defineEmits ([' update:modelValue' , ' changed' ]);
8474
85- function updateClient(newValue : string ) {
86- model .value = newValue ;
87- nextTick (() => {
88- emit (' changed' );
89- });
75+ function updateValue(client : { id: string | null ; name: string }) {
76+ model .value = client .id ;
77+ emit (' changed' );
9078}
91-
92- function moveHighlightUp() {
93- if (highlightedItem .value ) {
94- const currentHightlightedIndex = filteredClients .value .indexOf (
95- highlightedItem .value
96- );
97- if (currentHightlightedIndex === 0 ) {
98- highlightedItemId .value =
99- filteredClients .value [filteredClients .value .length - 1 ].id ;
100- } else {
101- highlightedItemId .value =
102- filteredClients .value [currentHightlightedIndex - 1 ].id ;
103- }
104- }
105- }
106-
107- function moveHighlightDown() {
108- if (highlightedItem .value ) {
109- const currentHightlightedIndex = filteredClients .value .indexOf (
110- highlightedItem .value
111- );
112- if (currentHightlightedIndex === filteredClients .value .length - 1 ) {
113- highlightedItemId .value = filteredClients .value [0 ].id ;
114- } else {
115- highlightedItemId .value =
116- filteredClients .value [currentHightlightedIndex + 1 ].id ;
117- }
118- }
119- }
120-
121- const highlightedItemId = ref <string | null >(null );
122- const highlightedItem = computed (() => {
123- return props .clients .find (
124- (client ) => client .id === highlightedItemId .value
125- );
126- });
12779 </script >
12880
12981<template >
130- <Dropdown v-model =" open" width = " 120 " :close-on-content-click = " true " >
82+ <Dropdown v-model =" open" align = " start " width = " 60 " >
13183 <template #trigger >
13284 <slot name =" trigger" ></slot >
13385 </template >
13486 <template #content >
135- <input
136- ref =" searchInput"
137- :value =" searchValue"
138- data-testid =" client_dropdown_search"
139- class =" bg-card-background border-0 placeholder-muted text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
140- placeholder =" Search for a client..."
141- @input =" updateSearchValue"
142- @keydown.enter =" addClientIfNoneExists"
143- @keydown.up.prevent =" moveHighlightUp"
144- @keydown.down.prevent =" moveHighlightDown" />
145- <div ref =" dropdownViewport" class =" w-60 max-h-60 overflow-y-scroll" >
146- <div
147- v-if ="
148- searchValue.length > 0 && filteredClients.length === 0
149- "
150- class =" bg-card-background-active"
151- @click =" addClientIfNoneExists" >
152- <div
153- class =" flex space-x-3 items-center px-4 py-3 text-xs text-text-primary font-medium border-t rounded-b-lg border-card-background-separator" >
154- <PlusCircleIcon
155- class =" w-5 flex-shrink-0" ></PlusCircleIcon >
156- <span >Add "{{ searchValue }}" as a new Client</span >
157- </div >
158- </div >
159- <div v-else ></div >
160- <div
161- v-for =" client in filteredClients"
162- :key =" client.id"
163- role =" option"
164- :value =" client.id"
165- :class =" {
166- 'bg-card-background-active':
167- client.id === highlightedItemId,
168- }"
169- data-testid =" client_dropdown_entries"
170- :data-client-id =" client.id" >
171- <ClientDropdownItem
172- :selected =" isClientSelected(client.id)"
173- :name =" client.name"
174- @click =" updateClient(client.id)" ></ClientDropdownItem >
175- </div >
176- </div >
87+ <UseFocusTrap
88+ v-if =" open"
89+ :options =" { immediate: true, allowOutsideClick: true }" >
90+ <ComboboxRoot
91+ v-model:search-term =" searchValue"
92+ :open =" open"
93+ :model-value =" currentClient"
94+ class =" relative"
95+ @update:model-value =" updateValue" >
96+ <ComboboxAnchor >
97+ <ComboboxInput
98+ ref =" searchInput"
99+ class =" bg-card-background border-0 placeholder-muted text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
100+ placeholder =" Search for a client..." />
101+ </ComboboxAnchor >
102+ <ComboboxContent >
103+ <ComboboxViewport
104+ class =" w-60 max-h-60 overflow-y-scroll" >
105+ <ComboboxItem
106+ :value =" { id: null, name: 'No Client' }"
107+ class =" data-[highlighted]:bg-card-background-active" >
108+ <ClientDropdownItem
109+ :selected =" model === null"
110+ name =" No Client" />
111+ </ComboboxItem >
112+ <ComboboxItem
113+ v-for =" client in filteredClients"
114+ :key =" client.id"
115+ :value =" client"
116+ class =" data-[highlighted]:bg-card-background-active"
117+ :data-client-id =" client.id" >
118+ <ClientDropdownItem
119+ :selected =" isClientSelected(client.id)"
120+ :name =" client.name" />
121+ </ComboboxItem >
122+ <div
123+ v-if ="
124+ searchValue.length > 0 &&
125+ filteredClients.length === 0
126+ "
127+ class =" bg-card-background-active" >
128+ <div
129+ class =" flex space-x-3 items-center px-4 py-3 text-xs text-text-primary font-medium border-t rounded-b-lg border-card-background-separator"
130+ @click =" addClientIfNoneExists" >
131+ <PlusCircleIcon class =" w-5 flex-shrink-0" />
132+ <span
133+ >Add "{{ searchValue }}" as a new
134+ Client</span
135+ >
136+ </div >
137+ </div >
138+ </ComboboxViewport >
139+ </ComboboxContent >
140+ </ComboboxRoot >
141+ </UseFocusTrap >
177142 </template >
178143 </Dropdown >
179144</template >
0 commit comments