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,17 @@
<script setup lang="ts" generic="T">
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import { type Component, computed, nextTick, ref, watch } from 'vue';
import MultiselectDropdownItem from '@/packages/ui/src/Input/MultiselectDropdownItem.vue';
import { computed, ref, watch } from 'vue';
import Checkbox from '@/packages/ui/src/Input/Checkbox.vue';
import {
ComboboxAnchor,
ComboboxContent,
ComboboxInput,
ComboboxItem,
ComboboxRoot,
ComboboxViewport,
} from 'radix-vue';
const NONE_ID = 'none';
const model = defineModel<string[]>({
default: [],
@@ -12,164 +22,98 @@ const props = defineProps<{
searchPlaceholder: string;
getKeyFromItem: (item: T) => string;
getNameForItem: (item: T) => string;
noItemLabel?: string;
}>();
const searchInput = ref<HTMLInputElement | null>(null);
const open = ref(false);
const dropdownViewport = ref<Component | null>(null);
const searchValue = ref('');
const sortedItems = ref<T[]>([]);
function isItemSelected(id: string) {
return model.value.includes(id);
}
watch(open, (isOpen) => {
if (isOpen) {
searchValue.value = '';
sortedItems.value = [...props.items].sort((a, b) => {
const aSelected = model.value.includes(props.getKeyFromItem(a)) ? 0 : 1;
const bSelected = model.value.includes(props.getKeyFromItem(b)) ? 0 : 1;
return aSelected - bSelected;
});
}
});
function addOrRemoveItemFromSelection(id: string) {
const filteredItems = computed(() => {
const search = searchValue.value.toLowerCase().trim();
if (!search) return sortedItems.value;
return sortedItems.value.filter((item) => props.getNameForItem(item).toLowerCase().includes(search));
});
const showNoItem = computed(() => {
if (!props.noItemLabel) return false;
const search = searchValue.value.toLowerCase().trim();
if (!search) return true;
return props.noItemLabel.toLowerCase().includes(search);
});
function toggleItem(id: string) {
if (model.value.includes(id)) {
model.value = model.value.filter((itemId) => itemId !== id);
} else {
model.value.push(id);
model.value = [...model.value, id];
}
emit('changed');
}
watch(open, (isOpen) => {
if (isOpen) {
nextTick(() => {
searchInput.value?.focus();
});
// sort tags alphabetically
[...props.items].sort((a, b) => {
const aIsSelected = model.value.includes(props.getKeyFromItem(a));
const bIsSelected = model.value.includes(props.getKeyFromItem(b));
if (aIsSelected === bIsSelected) {
return props.getNameForItem(a).localeCompare(props.getNameForItem(b));
}
return model.value.includes(props.getKeyFromItem(a)) ? -1 : 1;
});
nextTick(() => {
if (filteredItems.value.length > 0) {
highlightedItemId.value = props.getKeyFromItem(filteredItems.value[0]);
}
});
}
});
const filteredItems = computed<T[]>(() => {
return props.items.filter((item: T) => {
return props
.getNameForItem(item)
.toLowerCase()
.includes(searchValue.value?.toLowerCase()?.trim() || '');
});
});
watch(filteredItems, () => {
if (filteredItems.value.length > 0) {
highlightedItemId.value = props.getKeyFromItem(filteredItems.value[0]);
}
});
function updateSearchValue(event: Event) {
const newInput = (event.target as HTMLInputElement).value;
if (newInput === ' ') {
searchValue.value = '';
const highlightedTagId = highlightedItemId.value;
if (highlightedTagId) {
const highlightedItem = props.items.find(
(item) => props.getKeyFromItem(item) === highlightedTagId
);
if (highlightedItem) {
addOrRemoveItemFromSelection(props.getKeyFromItem(highlightedItem));
}
}
} else {
searchValue.value = newInput;
}
}
const emit = defineEmits(['update:modelValue', 'changed']);
function toggleItem(newValue: string | null) {
if (newValue !== null) {
if (model.value.includes(newValue)) {
model.value = [...model.value].filter((id) => id !== newValue);
} else {
model.value = [...model.value, newValue];
}
emit('changed');
}
}
function moveHighlightUp() {
if (highlightedItem.value) {
const currentHightlightedIndex = filteredItems.value.indexOf(highlightedItem.value);
if (currentHightlightedIndex === 0) {
highlightedItemId.value = props.getKeyFromItem(
filteredItems.value[filteredItems.value.length - 1]
);
} else {
highlightedItemId.value = props.getKeyFromItem(
filteredItems.value[currentHightlightedIndex - 1]
);
}
}
}
function moveHighlightDown() {
if (highlightedItem.value) {
const currentHightlightedIndex = filteredItems.value.indexOf(highlightedItem.value);
if (currentHightlightedIndex === filteredItems.value.length - 1) {
highlightedItemId.value = props.getKeyFromItem(filteredItems.value[0]);
} else {
highlightedItemId.value = props.getKeyFromItem(
filteredItems.value[currentHightlightedIndex + 1]
);
}
}
}
const highlightedItemId = ref<string | null>(null);
const highlightedItem = computed(() => {
return props.items.find((item) => props.getKeyFromItem(item) === highlightedItemId.value);
});
const emit = defineEmits(['update:modelValue', 'changed', 'submit']);
</script>
<template>
<Dropdown v-model="open" align="start" :close-on-content-click="false">
<Dropdown v-model="open" align="start" :close-on-content-click="false" @submit="emit('submit')">
<template #trigger>
<slot name="trigger"></slot>
</template>
<template #content>
<input
ref="searchInput"
:value="searchValue"
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="searchPlaceholder"
@input="updateSearchValue"
@keydown.up.prevent="moveHighlightUp"
@keydown.down.prevent="moveHighlightDown"
@keydown.enter="toggleItem(highlightedItemId)" />
<div ref="dropdownViewport" class="min-w-60 max-w-80 max-h-60 overflow-y-scroll">
<div
v-for="item in filteredItems"
:key="props.getKeyFromItem(item)"
role="option"
:value="props.getKeyFromItem(item)"
:class="{
'bg-card-background-active':
props.getKeyFromItem(item) === highlightedItemId,
}"
:data-item-id="props.getKeyFromItem(item)">
<MultiselectDropdownItem
:selected="isItemSelected(props.getKeyFromItem(item))"
:name="props.getNameForItem(item)"
@click="toggleItem(props.getKeyFromItem(item))"></MultiselectDropdownItem>
</div>
</div>
<ComboboxRoot
v-model:search-term="searchValue"
:open="open"
class="p-2"
:filter-function="(val: string[]) => val">
<ComboboxAnchor>
<ComboboxInput
class="w-full rounded-md border border-input-border bg-input-background px-3 py-1.5 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none"
:placeholder="searchPlaceholder" />
</ComboboxAnchor>
<ComboboxContent
:dismiss-able="false"
position="inline"
class="mt-2 min-w-60 max-w-80 max-h-60 overflow-y-auto">
<ComboboxViewport>
<ComboboxItem
v-if="showNoItem"
:value="NONE_ID"
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary data-[highlighted]:bg-card-background-active cursor-default"
@select.prevent="toggleItem(NONE_ID)">
<Checkbox
:checked="model.includes(NONE_ID)"
aria-hidden="true"
:tabindex="-1"
class="pointer-events-none" />
<span class="truncate">{{ noItemLabel }}</span>
</ComboboxItem>
<ComboboxItem
v-for="item in filteredItems"
:key="getKeyFromItem(item)"
:value="getKeyFromItem(item)"
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary data-[highlighted]:bg-card-background-active cursor-default"
@select.prevent="toggleItem(getKeyFromItem(item))">
<Checkbox
:checked="model.includes(getKeyFromItem(item))"
aria-hidden="true"
:tabindex="-1"
class="pointer-events-none" />
<span class="truncate">{{ getNameForItem(item) }}</span>
</ComboboxItem>
</ComboboxViewport>
</ComboboxContent>
</ComboboxRoot>
</template>
</Dropdown>
</template>
<style scoped></style>

View File

@@ -1,28 +0,0 @@
<script setup lang="ts">
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
import { computed } from 'vue';
import { twMerge } from 'tailwind-merge';
const props = defineProps<{
name: string;
selected: boolean;
}>();
const iconClasses = computed(() => {
if (props.selected) {
return 'text-input-select-active';
} else {
return 'text-text-quaternary opacity-50';
}
});
</script>
<template>
<div
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<CheckCircleIcon :class="twMerge(iconClasses, 'w-5')"></CheckCircleIcon>
<span class="flex-1 min-w-0 overflow-ellipsis overflow-hidden">{{ name }}</span>
</div>
</template>
<style scoped></style>

View File

@@ -1,188 +0,0 @@
<script setup lang="ts" generic="T">
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import SelectDropdownItem from '@/packages/ui/src/Input/SelectDropdownItem.vue';
import { onKeyStroke } from '@vueuse/core';
import { twMerge } from 'tailwind-merge';
const model = defineModel<string | null>({
default: null,
});
const open = defineModel('open', {
default: false,
});
const props = withDefaults(
defineProps<{
items: T[];
getKeyFromItem: (item: T) => string | null;
getNameForItem: (item: T) => string;
align?: 'center' | 'end' | 'start';
class?: string;
}>(),
{
align: 'start',
}
);
const dropdownViewport = ref<HTMLDivElement | null>(null);
const searchValue = ref('');
// DropdownMultiselect
const filteredItems = computed<T[]>(() => {
return props.items.filter((item: T) => {
return props
.getNameForItem(item)
.toLowerCase()
.includes(searchValue.value?.toLowerCase()?.trim() || '');
});
});
const highlightedItemId = ref<string | null>(model.value);
watch(model, () => {
if (model.value) {
highlightedItemId.value = model.value;
}
});
onMounted(() => {
if (!highlightedItemId.value) {
resetHightlightedItem();
}
});
watch(filteredItems, () => {
resetHightlightedItem();
});
function resetHightlightedItem() {
if (
filteredItems.value.length > 0 &&
filteredItems.value.find(
(item) => props.getKeyFromItem(item) === highlightedItemId.value
) === undefined
) {
highlightedItemId.value = props.getKeyFromItem(filteredItems.value[0]);
}
}
watch(highlightedItemId, () => {
if (highlightedItemId.value) {
const highlightedDomElement = dropdownViewport.value?.querySelector(
`[data-select-id="${highlightedItemId.value}"]`
) as HTMLElement;
highlightedDomElement?.scrollIntoView({
block: 'nearest',
inline: 'nearest',
});
}
});
const emit = defineEmits(['update:modelValue', 'changed']);
function setItem(newValue: string | null) {
model.value = newValue;
emit('changed');
open.value = false;
}
function moveHighlightUp() {
if (highlightedItem.value) {
const currentHightlightedIndex = filteredItems.value.indexOf(highlightedItem.value);
if (currentHightlightedIndex === 0) {
highlightedItemId.value = props.getKeyFromItem(
filteredItems.value[filteredItems.value.length - 1]
);
} else {
highlightedItemId.value = props.getKeyFromItem(
filteredItems.value[currentHightlightedIndex - 1]
);
}
}
}
function moveHighlightDown() {
if (highlightedItem.value) {
const currentHightlightedIndex = filteredItems.value.indexOf(highlightedItem.value);
if (currentHightlightedIndex === filteredItems.value.length - 1) {
highlightedItemId.value = props.getKeyFromItem(filteredItems.value[0]);
} else {
highlightedItemId.value = props.getKeyFromItem(
filteredItems.value[currentHightlightedIndex + 1]
);
}
}
}
const highlightedItem = computed(() => {
return props.items.find((item) => props.getKeyFromItem(item) === highlightedItemId.value);
});
onKeyStroke('ArrowDown', (e) => {
if (open.value === true) {
moveHighlightDown();
e.preventDefault();
}
});
onKeyStroke('ArrowUp', (e) => {
if (open.value === true) {
moveHighlightUp();
e.preventDefault();
}
});
onKeyStroke('Enter', (e) => {
if (open.value === true) {
setItem(highlightedItemId.value);
e.preventDefault();
}
});
watch(open, () => {
if (open.value === true) {
nextTick(() => {
const highlightedDomElement = dropdownViewport.value?.querySelector(
`[data-select-id="${model.value}"]`
) as HTMLElement;
dropdownViewport.value?.scrollTo({
top: highlightedDomElement?.offsetTop ?? 0,
behavior: 'instant',
});
});
}
});
</script>
<template>
<Dropdown v-model="open" :align="align" :close-on-content-click="false">
<template #trigger>
<slot name="trigger"> </slot>
</template>
<template #content>
<div
ref="dropdownViewport"
:class="twMerge('w-60 py-1.5 max-h-60 overflow-y-scroll', props.class)">
<div
v-for="item in filteredItems"
:key="props.getKeyFromItem(item) ?? 'none'"
role="option"
:data-select-id="props.getKeyFromItem(item)"
:value="props.getKeyFromItem(item)"
:data-item-id="props.getKeyFromItem(item)">
<SelectDropdownItem
:highlighted="props.getKeyFromItem(item) === highlightedItemId"
:selected="props.getKeyFromItem(item) === model"
:name="props.getNameForItem(item)"
@mouseenter="highlightedItemId = props.getKeyFromItem(item)"
@click="setItem(props.getKeyFromItem(item))"></SelectDropdownItem>
</div>
</div>
</template>
</Dropdown>
</template>
<style scoped></style>

View File

@@ -1,26 +0,0 @@
<script setup lang="ts">
import { twMerge } from 'tailwind-merge';
const props = defineProps<{
name: string;
selected: boolean;
highlighted: boolean;
}>();
</script>
<template>
<div class="px-1">
<div
:class="
twMerge(
'flex items-center space-x-3 w-full px-1.5 py-1.5 rounded text-start text-sm font-medium leading-5 text-text-primary focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out cursor-pointer ',
props.highlighted && 'bg-card-background-active',
props.selected ? 'bg-accent-300/20' : 'hover:bg-card-background-active'
)
">
<span>{{ name }}</span>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -1,162 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
import { useFocus } from '@vueuse/core';
import { SelectDropdown, TextInput } from '@/packages/ui/src';
import { twMerge } from 'tailwind-merge';
// This has to be a localized timestamp, not UTC
const model = defineModel<string | null>({
default: null,
});
const props = withDefaults(
defineProps<{
size?: 'base' | 'large';
focus?: boolean;
}>(),
{
size: 'base',
focus: false,
}
);
function updateTime(event: Event) {
const target = event.target as HTMLInputElement;
const newValue = target.value.trim();
if (newValue.split(':').length === 2) {
const [hours, minutes] = newValue.split(':');
if (!isNaN(parseInt(hours)) && !isNaN(parseInt(minutes))) {
model.value = getLocalizedDayJs(model.value)
.set('hours', Math.min(parseInt(hours), 23))
.set('minutes', Math.min(parseInt(minutes), 59))
.format();
emit('changed', model.value);
}
}
// check if input is only numbers
else if (/^\d+$/.test(newValue)) {
if (newValue.length === 4) {
// parse 1300 to 13:00
const [hours, minutes] = [newValue.slice(0, 2), newValue.slice(2, 4)];
model.value = getLocalizedDayJs(model.value)
.set('hours', Math.min(parseInt(hours), 23))
.set('minutes', Math.min(parseInt(minutes), 59))
.format();
emit('changed', model.value);
} else if (newValue.length === 3) {
// parse 130 to 01:30
const [hours, minutes] = [newValue.slice(0, 1), newValue.slice(1, 3)];
model.value = getLocalizedDayJs(model.value)
.set('hours', Math.min(parseInt(hours), 23))
.set('minutes', Math.min(parseInt(minutes), 59))
.format();
emit('changed', model.value);
} else if (newValue.length === 2) {
// parse 13 to 13:00
model.value = getLocalizedDayJs(model.value)
.set('hours', Math.min(parseInt(newValue), 23))
.set('minutes', 0)
.format();
emit('changed', model.value);
} else if (newValue.length === 1) {
// parse 1 to 01:00
model.value = getLocalizedDayJs(model.value)
.set('hours', Math.min(parseInt(newValue), 23))
.set('minutes', 0)
.format();
emit('changed', model.value);
}
}
inputValue.value = getLocalizedDayJs(model.value).format('HH:mm');
}
watch(model, (value) => {
inputValue.value = value ? getLocalizedDayJs(value).format('HH:mm') : null;
});
const timeInput = ref<HTMLInputElement | null>(null);
const emit = defineEmits(['changed']);
useFocus(timeInput, { initialValue: props.focus });
type TimeOption = {
timestamp: string;
name: string;
};
const getStartOptions = computed<TimeOption[]>(() => {
// options for the entire day in 15 minute intervals
const options = [];
for (let hour = 0; hour < 24; hour++) {
for (let minute = 0; minute < 60; minute += 15) {
const timestamp = getLocalizedDayJs(model.value)
.set('hour', hour)
.set('minute', minute)
.format();
const name = getLocalizedDayJs(model.value)
.set('hour', hour)
.set('minute', minute)
.format('HH:mm');
options.push({ timestamp, name });
}
}
return options;
});
const inputValue = ref(model.value ? getLocalizedDayJs(model.value).format('HH:mm') : null);
const open = ref(false);
const closestValue = computed({
get() {
const target = getDayJsInstance()(model.value);
let closestDiff: number | null = null;
let closest = target;
for (const option of getStartOptions.value) {
const diff = Math.abs(getDayJsInstance()(option.timestamp).diff(target));
if (closestDiff === null || diff < closestDiff) {
closestDiff = diff;
closest = getDayJsInstance()(option.timestamp);
}
}
return closest.format();
},
set(value: string) {
model.value = value;
emit('changed', model.value);
},
});
</script>
<template>
<div class="flex min-w-0 items-center justify-center text-text-primary">
<SelectDropdown
v-model="closestValue"
v-model:open="open"
:class="twMerge('mine-w-0 w-24', size === 'large' && 'w-28')"
:get-key-from-item="(item: TimeOption) => item.timestamp"
:get-name-for-item="(item: TimeOption) => item.name"
:items="getStartOptions">
<template #trigger>
<TextInput
ref="timeInput"
v-model="inputValue"
:class="twMerge('text-center w-24 px-3 py-2', size === 'large' && 'w-28')"
data-testid="time_picker_input"
type="text"
@blur="updateTime"
@keydown.enter="
updateTime($event);
open = false;
"
@keydown.tab="open = false"
@focus="($event.target as HTMLInputElement).select()"
@mouseup="($event.target as HTMLInputElement).select()"
@click="($event.target as HTMLInputElement).select()"
@pointerup="($event.target as HTMLInputElement).select()"
@focusin="open = true" />
</template>
</SelectDropdown>
</div>
</template>
<style scoped></style>

View File

@@ -1,35 +0,0 @@
<script setup lang="ts">
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
defineProps<{
label: string;
}>();
</script>
<template>
<Dropdown align="end">
<template #trigger>
<button
class="focus-visible:outline-none focus-visible:bg-card-background rounded-full focus-visible:ring-2 focus-visible:ring-ring focus-visible:opacity-100 hover:bg-card-background group-hover:opacity-100 opacity-20 transition-opacity text-text-secondary"
:aria-label="label">
<svg
class="h-8 w-8 p-1 rounded-full"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92" />
</svg>
</button>
</template>
<template #content>
<slot></slot>
</template>
</Dropdown>
</template>
<style scoped></style>

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>({
@@ -25,38 +29,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 tag="button" 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,22 @@
<script setup lang="ts">
import { PlusCircleIcon } from '@heroicons/vue/20/solid';
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import { type Component, computed, nextTick, ref, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';
import MultiselectDropdownItem from '@/packages/ui/src/Input/MultiselectDropdownItem.vue';
import Checkbox from '@/packages/ui/src/Input/Checkbox.vue';
import type { Tag } from '@/packages/api/src';
import { UseFocusTrap } from '@vueuse/integrations/useFocusTrap/component';
import { Button } from '@/Components/ui/button';
import {
ComboboxAnchor,
ComboboxContent,
ComboboxInput,
ComboboxItem,
ComboboxRoot,
ComboboxViewport,
} from 'radix-vue';
const NONE_ID = 'none';
const NO_TAG_LABEL = 'No Tag';
const props = withDefaults(
defineProps<{
@@ -22,17 +33,34 @@ const model = defineModel<string[]>({
default: [],
});
const searchInput = ref<HTMLInputElement | null>(null);
const open = ref(false);
const dropdownViewport = ref<Component | null>(null);
const searchValue = ref('');
const sortedTags = ref<Tag[]>([]);
function isTagSelected(id: string) {
return model.value.includes(id);
}
watch(open, (isOpen) => {
if (isOpen) {
searchValue.value = '';
sortedTags.value = [...props.tags].sort((a, b) => {
const aSelected = model.value.includes(a.id) ? 0 : 1;
const bSelected = model.value.includes(b.id) ? 0 : 1;
return aSelected - bSelected;
});
}
});
function addOrRemoveTagFromSelection(id: string) {
const filteredTags = computed(() => {
const search = searchValue.value.toLowerCase().trim();
if (!search) return sortedTags.value;
return sortedTags.value.filter((tag) => tag.name.toLowerCase().includes(search));
});
const showNoTag = computed(() => {
const search = searchValue.value.toLowerCase().trim();
if (!search) return true;
return NO_TAG_LABEL.toLowerCase().includes(search);
});
function toggleTag(id: string) {
if (model.value.includes(id)) {
model.value = model.value.filter((tagId) => tagId !== id);
} else {
@@ -41,115 +69,20 @@ function addOrRemoveTagFromSelection(id: string) {
emit('changed');
}
const sortedTags = ref(props.tags);
watch(open, (isOpen) => {
if (isOpen) {
nextTick(() => {
searchInput.value?.focus();
});
// sort tags alphabetically
sortedTags.value = [...props.tags].sort((a, b) => {
const aIsSelected = model.value.includes(a.id);
const bIsSelected = model.value.includes(b.id);
if (aIsSelected === bIsSelected) {
return a.name.localeCompare(b.name);
}
return model.value.includes(a.id) ? -1 : 1;
});
nextTick(() => {
if (filteredTags.value.length > 0) {
highlightedItemId.value = filteredTags.value[0].id;
}
});
}
});
const filteredTags = computed(() => {
return sortedTags.value.filter((tag: Tag) => {
return tag.name.toLowerCase().includes(searchValue.value?.toLowerCase()?.trim() || '');
});
});
async function createAndAddTag(name: string) {
const newTag = await props.createTag(name);
if (newTag) {
addOrRemoveTagFromSelection(newTag.id);
toggleTag(newTag.id);
}
searchValue.value = '';
return newTag;
}
async function addTagIfNoneExists() {
if (highlightedItemId.value) {
addOrRemoveTagFromSelection(highlightedItemId.value);
}
}
watch(filteredTags, () => {
if (filteredTags.value.length > 0) {
highlightedItemId.value = filteredTags.value[0].id;
}
});
function updateSearchValue(event: Event) {
const newInput = (event.target as HTMLInputElement).value;
if (newInput === ' ') {
searchValue.value = '';
const highlightedTagId = highlightedItemId.value;
if (highlightedTagId) {
const highlightedTag = props.tags.find((tag) => tag.id === highlightedTagId);
if (highlightedTag) {
addOrRemoveTagFromSelection(highlightedTag.id);
}
}
} else {
searchValue.value = newInput;
}
}
const emit = defineEmits<{
changed: [];
submit: [];
}>();
function toggleTag(newValue: string) {
if (model.value.includes(newValue)) {
model.value = [...model.value].filter((id) => id !== newValue);
} else {
model.value = [...model.value, newValue];
}
emit('changed');
}
function moveHighlightUp() {
if (highlightedItem.value) {
const currentHightlightedIndex = filteredTags.value.indexOf(highlightedItem.value);
if (currentHightlightedIndex === 0) {
highlightedItemId.value = filteredTags.value[filteredTags.value.length - 1].id;
} else {
highlightedItemId.value = filteredTags.value[currentHightlightedIndex - 1].id;
}
}
}
function moveHighlightDown() {
if (highlightedItem.value) {
const currentHightlightedIndex = filteredTags.value.indexOf(highlightedItem.value);
if (currentHightlightedIndex === filteredTags.value.length - 1) {
highlightedItemId.value = filteredTags.value[0].id;
} else {
highlightedItemId.value = filteredTags.value[currentHightlightedIndex + 1].id;
}
}
}
const highlightedItemId = ref<string | null>(null);
const highlightedItem = computed(() => {
return props.tags.find((tag) => tag.id === highlightedItemId.value);
});
const showCreateTagModal = ref(false);
</script>
@@ -166,50 +99,65 @@ const showCreateTagModal = ref(false);
<slot name="trigger"></slot>
</template>
<template #content>
<UseFocusTrap v-if="open" :options="{ immediate: true, allowOutsideClick: true }">
<input
ref="searchInput"
:value="searchValue"
data-testid="tag_dropdown_search"
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 Tag..."
@input="updateSearchValue"
@keydown.esc.prevent="open = false"
@keydown.enter="addTagIfNoneExists"
@keydown.up.prevent="moveHighlightUp"
@keydown.down.prevent="moveHighlightDown" />
<div ref="dropdownViewport" class="w-60 max-h-60 overflow-y-scroll">
<div
v-for="tag in filteredTags"
:key="tag.id"
role="option"
:value="tag.id"
:class="{
'bg-card-background-active': tag.id === highlightedItemId,
}"
data-testid="tag_dropdown_entries"
:data-tag-id="tag.id">
<MultiselectDropdownItem
:selected="isTagSelected(tag.id)"
:name="tag.name"
@click="toggleTag(tag.id)"></MultiselectDropdownItem>
</div>
</div>
<div class="hover:bg-card-background-active rounded-b-lg">
<button
class="text-text-primary w-full flex space-x-3 items-center px-4 py-3 text-xs font-semibold border-t border-card-background-separator"
<ComboboxRoot
v-model:search-term="searchValue"
:open="open"
class="p-2"
:filter-function="(val: string[]) => val">
<ComboboxAnchor>
<ComboboxInput
data-testid="tag_dropdown_search"
class="w-full rounded-md border border-input-border bg-input-background px-3 py-1.5 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none"
placeholder="Search for a Tag..." />
</ComboboxAnchor>
<ComboboxContent
:dismiss-able="false"
position="inline"
class="mt-2 w-60 max-h-60 overflow-y-auto">
<ComboboxViewport>
<ComboboxItem
v-if="showNoTag"
:value="NONE_ID"
data-testid="tag_dropdown_entries"
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary data-[highlighted]:bg-card-background-active cursor-default"
@select.prevent="toggleTag(NONE_ID)">
<Checkbox
:checked="model.includes(NONE_ID)"
aria-hidden="true"
:tabindex="-1"
class="pointer-events-none" />
<span class="truncate">{{ NO_TAG_LABEL }}</span>
</ComboboxItem>
<ComboboxItem
v-for="tag in filteredTags"
:key="tag.id"
:value="tag.id"
data-testid="tag_dropdown_entries"
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary data-[highlighted]:bg-card-background-active cursor-default"
@select.prevent="toggleTag(tag.id)">
<Checkbox
:checked="model.includes(tag.id)"
aria-hidden="true"
:tabindex="-1"
class="pointer-events-none" />
<span class="truncate">{{ tag.name }}</span>
</ComboboxItem>
</ComboboxViewport>
</ComboboxContent>
<div class="mt-1 border-t border-card-background-separator pt-1">
<Button
variant="ghost"
size="sm"
class="w-full justify-start gap-2 px-2 py-1.5 text-sm text-text-primary"
@click="
open = false;
showCreateTagModal = true;
">
<PlusCircleIcon
class="w-5 flex-shrink-0 text-icon-default"></PlusCircleIcon>
<PlusCircleIcon class="w-4 h-4 flex-shrink-0 text-icon-default" />
<span>Create new Tag</span>
</button>
</Button>
</div>
</UseFocusTrap>
</ComboboxRoot>
</template>
</Dropdown>
</template>
<style scoped></style>

View File

@@ -18,7 +18,13 @@ import type {
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import { Badge } from '@/packages/ui/src';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import SelectDropdown from '@/packages/ui/src/Input/SelectDropdown.vue';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
import DurationHumanInput from '@/packages/ui/src/Input/DurationHumanInput.vue';
@@ -129,11 +135,6 @@ const billableProxy = computed({
timeEntry.value.billable = value === 'true';
},
});
type BillableOption = {
label: string;
value: string;
};
</script>
<template>
@@ -196,29 +197,20 @@ type BillableOption = {
</TagDropdown>
</div>
<div class="flex-col">
<SelectDropdown
v-model="billableProxy"
:get-key-from-item="(item: BillableOption) => item.value"
:get-name-for-item="(item: BillableOption) => item.label"
:items="[
{
label: 'Billable',
value: 'true',
},
{
label: 'Non Billable',
value: 'false',
},
]">
<template #trigger>
<Badge class="bg-input-background" tag="button" size="xlarge">
<BillableIcon class="h-4"></BillableIcon>
<Select v-model="billableProxy">
<SelectTrigger size="small" :show-chevron="false">
<SelectValue class="flex items-center gap-2">
<BillableIcon class="h-4 text-icon-default" />
<span>{{
timeEntry.billable ? 'Billable' : 'Non-Billable'
}}</span>
</Badge>
</template>
</SelectDropdown>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="true">Billable</SelectItem>
<SelectItem value="false">Non Billable</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>

View File

@@ -18,7 +18,13 @@ import type {
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import { Badge } from '@/packages/ui/src';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import SelectDropdown from '@/packages/ui/src/Input/SelectDropdown.vue';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
import DurationHumanInput from '@/packages/ui/src/Input/DurationHumanInput.vue';
@@ -124,11 +130,6 @@ const billableProxy = computed({
}
},
});
type BillableOption = {
label: string;
value: string;
};
</script>
<template>
@@ -198,34 +199,22 @@ type BillableOption = {
</TagDropdown>
</div>
<div class="flex-col">
<SelectDropdown
v-model="billableProxy"
:get-key-from-item="(item: BillableOption) => item.value"
:get-name-for-item="(item: BillableOption) => item.label"
:items="[
{
label: 'Billable',
value: 'true',
},
{
label: 'Non Billable',
value: 'false',
},
]">
<template #trigger>
<Badge
class="bg-input-background"
tag="button"
size="xlarge">
<BillableIcon class="h-4"></BillableIcon>
<Select v-model="billableProxy">
<SelectTrigger size="small" :show-chevron="false">
<SelectValue class="flex items-center gap-2">
<BillableIcon class="h-4 text-icon-default" />
<span>{{
editableTimeEntry.billable
? 'Billable'
: 'Non-Billable'
}}</span>
</Badge>
</template>
</SelectDropdown>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="true">Billable</SelectItem>
<SelectItem value="false">Non Billable</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>

View File

@@ -15,7 +15,13 @@ import {
type UpdateMultipleTimeEntriesChangeset,
} from '@/packages/api/src';
import { Badge, Checkbox } from '@/packages/ui/src';
import SelectDropdown from '../Input/SelectDropdown.vue';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import type { Tag, Task } from '@/packages/api/src';
@@ -126,7 +132,6 @@ watch(removeAllTags, () => {
selectedTags.value = [];
}
});
type SelectOption = { label: string; value: string };
</script>
<template>
@@ -189,32 +194,22 @@ type SelectOption = { label: string; value: string };
<div class="space-y-2">
<InputLabel for="project" value="Billable" />
<div class="flex">
<SelectDropdown
v-model="timeEntryBillable"
:get-key-from-item="(item: SelectOption) => item.value"
:get-name-for-item="(item: SelectOption) => item.label"
:items="[
{
label: 'Keep current billable status',
value: 'do-not-update',
},
{
label: 'Billable',
value: 'billable',
},
{
label: 'Non Billable',
value: 'non-billable',
},
]">
<template #trigger>
<Badge tag="button" size="xlarge">
<span v-if="billable === undefined"> Set billable status </span>
<span v-else-if="billable === true"> Billable </span>
<span v-else> Non Billable </span></Badge
>
</template>
</SelectDropdown>
<Select v-model="timeEntryBillable">
<SelectTrigger size="small" :show-chevron="false">
<SelectValue>
<span v-if="billable === undefined">Set billable status</span>
<span v-else-if="billable === true">Billable</span>
<span v-else>Non Billable</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="do-not-update">
Keep current billable status
</SelectItem>
<SelectItem value="billable">Billable</SelectItem>
<SelectItem value="non-billable">Non Billable</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>

View File

@@ -26,14 +26,13 @@ import TimeTrackerRunningInDifferentOrganizationOverlay from './TimeTracker/Time
import TimeTrackerControls from './TimeTracker/TimeTrackerControls.vue';
import TimeTrackerMoreOptionsDropdown from './TimeTracker/TimeTrackerMoreOptionsDropdown.vue';
import CardTitle from './CardTitle.vue';
import SelectDropdown from './Input/SelectDropdown.vue';
import Badge from './Badge.vue';
import Checkbox from './Input/Checkbox.vue';
import TimeEntryGroupedTable from './TimeEntry/TimeEntryGroupedTable.vue';
import TimeEntryMassActionRow from './TimeEntry/TimeEntryMassActionRow.vue';
import TimeEntryCreateModal from './TimeEntry/TimeEntryCreateModal.vue';
import TimeEntryEditModal from './TimeEntry/TimeEntryEditModal.vue';
import MoreOptionsDropdown from './MoreOptionsDropdown.vue';
import FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vue';
import FullCalendarDayHeader from './FullCalendar/FullCalendarDayHeader.vue';
import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue';
@@ -69,12 +68,10 @@ export {
TimeTrackerControls,
TimeTrackerMoreOptionsDropdown,
CardTitle,
SelectDropdown,
Badge,
Checkbox,
TimeEntryGroupedTable,
TimeEntryMassActionRow,
MoreOptionsDropdown,
TimeEntryCreateModal,
TimeEntryEditModal,
FullCalendarEventContent,