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>