migrate select/multiselect components to Radix Vue primitives

This commit is contained in:
Gregor Vostrak
2026-02-02 00:56:06 +01:00
parent 3707f2469c
commit 756b423295
24 changed files with 615 additions and 930 deletions

View File

@@ -12,6 +12,10 @@ function getKeyFromItem(item: Client) {
function getNameForItem(item: Client) {
return item.name;
}
const emit = defineEmits<{
submit: [];
}>();
</script>
<template>
@@ -19,7 +23,9 @@ function getNameForItem(item: Client) {
search-placeholder="Search for a Client..."
:items="clients"
:get-key-from-item="getKeyFromItem"
:get-name-for-item="getNameForItem">
:get-name-for-item="getNameForItem"
no-item-label="No Client"
@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 { 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>

View File

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

View File

@@ -0,0 +1,151 @@
<script setup lang="ts">
import { CheckCircleIcon, TagIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
import { FolderIcon } from '@heroicons/vue/16/solid';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import ReportingRoundingControls from '@/Components/Common/Reporting/ReportingRoundingControls.vue';
import TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue';
import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.vue';
import MemberMultiselectDropdown from '@/Components/Common/Member/MemberMultiselectDropdown.vue';
import ReportingFilterBadge from '@/Components/Common/Reporting/ReportingFilterBadge.vue';
import ProjectMultiselectDropdown from '@/Components/Common/Project/ProjectMultiselectDropdown.vue';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import MainContainer from '@/packages/ui/src/MainContainer.vue';
import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import { useTagsQuery } from '@/utils/useTagsQuery';
import { useTagsStore } from '@/utils/useTags';
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
const selectedMembers = defineModel<string[]>('selectedMembers', { required: true });
const selectedProjects = defineModel<string[]>('selectedProjects', { required: true });
const selectedTasks = defineModel<string[]>('selectedTasks', { required: true });
const selectedClients = defineModel<string[]>('selectedClients', { required: true });
const selectedTags = defineModel<string[]>('selectedTags', { required: true });
const billable = defineModel<'true' | 'false' | null>('billable', { required: true });
const roundingEnabled = defineModel<boolean>('roundingEnabled', { required: true });
const roundingType = defineModel<TimeEntryRoundingType>('roundingType', { required: true });
const roundingMinutes = defineModel<number>('roundingMinutes', { required: true });
const startDate = defineModel<string>('startDate', { required: true });
const endDate = defineModel<string>('endDate', { required: true });
const emit = defineEmits<{
submit: [];
}>();
const { tags } = useTagsQuery();
async function createTag(name: string) {
return await useTagsStore().createTag(name);
}
</script>
<template>
<div class="py-2.5 w-full border-b border-default-background-separator">
<MainContainer class="sm:flex space-y-4 sm:space-y-0 justify-between">
<div class="flex flex-wrap items-center space-y-2 sm:space-y-0 space-x-3">
<div class="text-sm font-medium">Filters</div>
<MemberMultiselectDropdown
v-model="selectedMembers"
@submit="emit('submit')">
<template #trigger>
<ReportingFilterBadge
:count="selectedMembers.length"
:active="selectedMembers.length > 0"
title="Members"
:icon="UserGroupIcon" />
</template>
</MemberMultiselectDropdown>
<ProjectMultiselectDropdown
v-model="selectedProjects"
@submit="emit('submit')">
<template #trigger>
<ReportingFilterBadge
:count="selectedProjects.length"
:active="selectedProjects.length > 0"
title="Projects"
:icon="FolderIcon" />
</template>
</ProjectMultiselectDropdown>
<TaskMultiselectDropdown
v-model="selectedTasks"
@submit="emit('submit')">
<template #trigger>
<ReportingFilterBadge
:count="selectedTasks.length"
:active="selectedTasks.length > 0"
title="Tasks"
:icon="CheckCircleIcon" />
</template>
</TaskMultiselectDropdown>
<ClientMultiselectDropdown
v-model="selectedClients"
@submit="emit('submit')">
<template #trigger>
<ReportingFilterBadge
:count="selectedClients.length"
:active="selectedClients.length > 0"
title="Clients"
:icon="FolderIcon" />
</template>
</ClientMultiselectDropdown>
<TagDropdown
v-model="selectedTags"
:create-tag
:tags="tags"
@submit="emit('submit')">
<template #trigger>
<ReportingFilterBadge
:count="selectedTags.length"
:active="selectedTags.length > 0"
title="Tags"
:icon="TagIcon" />
</template>
</TagDropdown>
<Select v-model="billable" @update:model-value="emit('submit')">
<SelectTrigger
size="small"
variant="outline"
:active="billable !== null"
:show-chevron="false">
<SelectValue class="flex items-center gap-2">
<BillableIcon
class="h-4"
:class="
billable !== null
? 'dark:text-accent-300/80 text-accent-400/80'
: 'text-text-quaternary'
" />
<span class="text-text-secondary">{{
billable === 'false' ? 'Non Billable' : 'Billable'
}}</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">Both</SelectItem>
<SelectItem value="true">Billable</SelectItem>
<SelectItem value="false">Non Billable</SelectItem>
</SelectContent>
</Select>
<ReportingRoundingControls
v-model:enabled="roundingEnabled"
v-model:type="roundingType"
v-model:minutes="roundingMinutes"
@change="emit('submit')" />
</div>
<div>
<DateRangePicker
v-model:start="startDate"
v-model:end="endDate"
@submit="emit('submit')" />
</div>
</MainContainer>
</div>
</template>

View File

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

View File

@@ -4,19 +4,43 @@ import { ChevronDown } from 'lucide-vue-next';
import { SelectIcon, SelectTrigger, type SelectTriggerProps, useForwardProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';
const props = defineProps<
SelectTriggerProps & { size?: 'small'; class?: HTMLAttributes['class'] }
>();
const props = withDefaults(
defineProps<
SelectTriggerProps & {
size?: 'small';
class?: HTMLAttributes['class'];
showChevron?: boolean;
variant?: 'default' | 'outline';
active?: boolean;
}
>(),
{
showChevron: true,
variant: 'default',
active: false,
}
);
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
const { class: _, showChevron: __, variant: ___, active: ____, ...delegated } = props;
return delegated;
});
const forwardedProps = useForwardProps(delegatedProps);
const sizeClasses = computed(() => {
return props.size === 'small' ? 'h-[34px] text-sm' : 'h-[42px]';
return props.size === 'small' ? 'h-8 text-xs' : 'h-[42px] w-full text-sm';
});
const variantClasses = computed(() => {
if (props.variant === 'outline') {
if (props.active) {
return 'border border-accent-300/50 bg-accent-50 hover:bg-accent-100 dark:border-accent-300/50 dark:bg-accent-300/5 dark:hover:bg-accent-300/10';
}
return 'border shadow-xs hover:text-text-primary bg-card-background dark:bg-transparent border-input dark:border-input hover:bg-white/5';
}
return 'border border-input-border bg-input-background shadow-sm';
});
</script>
@@ -25,14 +49,15 @@ const sizeClasses = computed(() => {
v-bind="forwardedProps"
:class="
cn(
'flex w-full items-center justify-between whitespace-nowrap rounded-md border border-input-border bg-input-background px-3 py-2.5 shadow-sm data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:truncate text-start',
'flex items-center justify-between gap-3 whitespace-nowrap rounded-md px-3 py-2.5 data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:truncate text-start font-medium transition-colors',
sizeClasses,
variantClasses,
props.class
)
">
<slot />
<SelectIcon as-child>
<ChevronDown class="w-4 h-4 opacity-50 shrink-0" />
<SelectIcon v-if="showChevron" as-child>
<ChevronDown class="w-4 h-4 text-icon-default shrink-0" />
</SelectIcon>
</SelectTrigger>
</template>

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,

View File

@@ -28,7 +28,7 @@
--color-border-secondary: oklch(0.25 0.0098 268.31);
--color-border-tertiary: #2c2e33;
--color-border-quaternary: #393b42;
--color-input-border-active: rgba(255, 255, 255, 0.3);
--color-input-border-active: rgba(255, 255, 255, 0.15);
--theme-color-chart: var(--color-accent-200);
@@ -51,7 +51,7 @@
--theme-color-button-primary-border: rgba(var(--color-accent-300), 0.2);
--theme-color-button-primary-text: var(--color-text-primary);
--theme-color-input-background: transparent;
--theme-color-input-background: var(--color-bg-secondary);
--theme-color-input-select-active: rgb(var(--color-accent-300));
--theme-color-input-select-active-hover: rgb(var(--color-accent-200));

View File

@@ -0,0 +1,29 @@
import { useQuery } from '@tanstack/vue-query';
import { api, type AggregatedTimeEntriesQueryParams, type ReportingResponse } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { computed, type ComputedRef, unref } from 'vue';
export function useAggregatedTimeEntriesQuery(
queryKeyPrefix: string,
filterParams: ComputedRef<AggregatedTimeEntriesQueryParams>
) {
const query = useQuery<ReportingResponse>({
queryKey: computed(() => [
'aggregatedTimeEntries',
queryKeyPrefix,
getCurrentOrganizationId(),
unref(filterParams),
]),
queryFn: () =>
api.getAggregatedTimeEntries({
params: {
organization: getCurrentOrganizationId() || '',
},
queries: unref(filterParams),
}),
enabled: computed(() => !!getCurrentOrganizationId()),
staleTime: 1000 * 30,
});
return query;
}