combobox dialog components

This commit is contained in:
pa
2026-01-10 18:43:24 +09:00
committed by Natsumi
parent ac1887e27c
commit 926aacc15e
22 changed files with 897 additions and 7 deletions

15
package-lock.json generated
View File

@@ -25,6 +25,7 @@
"@sentry/vue": "^10.32.1",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/vue-table": "^8.21.3",
"@tanstack/vue-virtual": "^3.13.18",
"@types/jest": "^30.0.0",
"@types/node": "^25.0.3",
"@vitejs/plugin-vue": "^6.0.3",
@@ -5645,9 +5646,9 @@
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.13.14",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.14.tgz",
"integrity": "sha512-b5Uvd8J2dc7ICeX9SRb/wkCxWk7pUwN214eEPAQsqrsktSKTCmyLxOQWSMgogBByXclZeAdgZ3k4o0fIYUIBqQ==",
"version": "3.13.18",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.18.tgz",
"integrity": "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==",
"dev": true,
"license": "MIT",
"funding": {
@@ -5676,13 +5677,13 @@
}
},
"node_modules/@tanstack/vue-virtual": {
"version": "3.13.14",
"resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.14.tgz",
"integrity": "sha512-dLKQCWj0uu6Rc1OsTGiClpH75hyf92MvJ9YALAzWdblwImSFnxfXD0mu8yOI7PlxiDAcDA5Pq0Q47YvADAfyfg==",
"version": "3.13.18",
"resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.18.tgz",
"integrity": "sha512-6pT8HdHtTU5Z+t906cGdCroUNA5wHjFXsNss9gwk7QAr1VNZtz9IQCs2Nhx0gABK48c+OocHl2As+TMg8+Hy4A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.13.14"
"@tanstack/virtual-core": "3.13.18"
},
"funding": {
"type": "github",

View File

@@ -45,6 +45,7 @@
"@sentry/vue": "^10.32.1",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/vue-table": "^8.21.3",
"@tanstack/vue-virtual": "^3.13.18",
"@types/jest": "^30.0.0",
"@types/node": "^25.0.3",
"@vitejs/plugin-vue": "^6.0.3",

View File

@@ -0,0 +1,102 @@
<script setup>
import { ListboxRoot, useFilter, useForwardPropsEmits } from 'reka-ui';
import { reactive, ref, watch } from 'vue';
import { cn } from '@/lib/utils';
import { reactiveOmit } from '@vueuse/core';
import { provideCommandContext } from '.';
const props = defineProps({
modelValue: { type: null, required: false, default: '' },
defaultValue: { type: null, required: false },
multiple: { type: Boolean, required: false },
orientation: { type: String, required: false },
dir: { type: String, required: false },
disabled: { type: Boolean, required: false },
selectionBehavior: { type: String, required: false },
highlightOnHover: { type: Boolean, required: false },
by: { type: [String, Function], required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
name: { type: String, required: false },
required: { type: Boolean, required: false },
class: { type: null, required: false }
});
const emits = defineEmits(['update:modelValue', 'highlight', 'entryFocus', 'leave']);
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardPropsEmits(delegatedProps, emits);
const allItems = ref(new Map());
const allGroups = ref(new Map());
const { contains } = useFilter({ sensitivity: 'base' });
const filterState = reactive({
search: '',
filtered: {
/** The count of all visible items. */
count: 0,
/** Map from visible item id to its search score. */
items: new Map(),
/** Set of groups with at least one visible item. */
groups: new Set()
}
});
function filterItems() {
if (!filterState.search) {
filterState.filtered.count = allItems.value.size;
// Do nothing, each item will know to show itself because search is empty
return;
}
// Reset the groups
filterState.filtered.groups = new Set();
let itemCount = 0;
// Check which items should be included
for (const [id, value] of allItems.value) {
const score = contains(value, filterState.search);
filterState.filtered.items.set(id, score ? 1 : 0);
if (score) itemCount++;
}
// Check which groups have at least 1 item shown
for (const [groupId, group] of allGroups.value) {
for (const itemId of group) {
if (filterState.filtered.items.get(itemId) > 0) {
filterState.filtered.groups.add(groupId);
break;
}
}
}
filterState.filtered.count = itemCount;
}
watch(
() => filterState.search,
() => {
filterItems();
}
);
provideCommandContext({
allItems,
allGroups,
filterState
});
</script>
<template>
<ListboxRoot
data-slot="command"
v-bind="forwarded"
:class="
cn('bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md', props.class)
">
<slot />
</ListboxRoot>
</template>

View File

@@ -0,0 +1,35 @@
<script setup>
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { useForwardPropsEmits } from 'reka-ui';
import Command from './Command.vue';
const props = defineProps({
open: { type: Boolean, required: false },
defaultOpen: { type: Boolean, required: false },
modal: { type: Boolean, required: false },
title: { type: String, required: false, default: 'Command Palette' },
description: {
type: String,
required: false,
default: 'Search for a command to run...'
}
});
const emits = defineEmits(['update:open']);
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<Dialog v-slot="slotProps" v-bind="forwarded">
<DialogContent class="overflow-hidden p-0">
<DialogHeader class="sr-only">
<DialogTitle>{{ title }}</DialogTitle>
<DialogDescription>{{ description }}</DialogDescription>
</DialogHeader>
<Command>
<slot v-bind="slotProps" />
</Command>
</DialogContent>
</Dialog>
</template>

View File

@@ -0,0 +1,29 @@
<script setup>
import { Primitive } from 'reka-ui';
import { cn } from '@/lib/utils';
import { computed } from 'vue';
import { reactiveOmit } from '@vueuse/core';
import { useCommand } from '.';
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false }
});
const delegatedProps = reactiveOmit(props, 'class');
const { filterState } = useCommand();
const isRender = computed(() => !!filterState.search && filterState.filtered.count === 0);
</script>
<template>
<Primitive
v-if="isRender"
data-slot="command-empty"
v-bind="delegatedProps"
:class="cn('py-6 text-center text-sm', props.class)">
<slot />
</Primitive>
</template>

View File

@@ -0,0 +1,47 @@
<script setup>
import { ListboxGroup, ListboxGroupLabel, useId } from 'reka-ui';
import { computed, onMounted, onUnmounted } from 'vue';
import { cn } from '@/lib/utils';
import { reactiveOmit } from '@vueuse/core';
import { provideCommandGroupContext, useCommand } from '.';
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
heading: { type: String, required: false }
});
const delegatedProps = reactiveOmit(props, 'class');
const { allGroups, filterState } = useCommand();
const id = useId();
const isRender = computed(() => (!filterState.search ? true : filterState.filtered.groups.has(id)));
provideCommandGroupContext({ id });
onMounted(() => {
if (!allGroups.value.has(id)) allGroups.value.set(id, new Set());
});
onUnmounted(() => {
allGroups.value.delete(id);
});
</script>
<template>
<ListboxGroup
v-bind="delegatedProps"
:id="id"
data-slot="command-group"
:class="cn('text-foreground overflow-hidden p-1', props.class)"
:hidden="isRender ? undefined : true">
<ListboxGroupLabel
v-if="heading"
data-slot="command-group-heading"
class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
{{ heading }}
</ListboxGroupLabel>
<slot />
</ListboxGroup>
</template>

View File

@@ -0,0 +1,44 @@
<script setup>
import { ListboxFilter, useForwardProps } from 'reka-ui';
import { Search } from 'lucide-vue-next';
import { cn } from '@/lib/utils';
import { reactiveOmit } from '@vueuse/core';
import { useCommand } from '.';
defineOptions({
inheritAttrs: false
});
const props = defineProps({
modelValue: { type: String, required: false },
autoFocus: { type: Boolean, required: false },
disabled: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false }
});
const delegatedProps = reactiveOmit(props, 'class');
const forwardedProps = useForwardProps(delegatedProps);
const { filterState } = useCommand();
</script>
<template>
<div data-slot="command-input-wrapper" class="flex h-9 items-center gap-2 border-b px-3">
<Search class="size-4 shrink-0 opacity-50" />
<ListboxFilter
v-bind="{ ...forwardedProps, ...$attrs }"
v-model="filterState.search"
data-slot="command-input"
auto-focus
:class="
cn(
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
props.class
)
" />
</div>
</template>

View File

@@ -0,0 +1,84 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { ListboxItem, useForwardPropsEmits, useId } from 'reka-ui';
import { reactiveOmit, useCurrentElement } from '@vueuse/core';
import { cn } from '@/lib/utils';
import { useCommand, useCommandGroup } from '.';
const props = defineProps({
value: { type: null, required: true },
disabled: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false }
});
const emits = defineEmits(['select']);
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardPropsEmits(delegatedProps, emits);
const id = useId();
const { filterState, allItems, allGroups } = useCommand();
const groupContext = useCommandGroup();
const isRender = computed(() => {
if (!filterState.search) {
return true;
} else {
const filteredCurrentItem = filterState.filtered.items.get(id);
// If the filtered items is undefined means not in the all times map yet
// Do the first render to add into the map
if (filteredCurrentItem === undefined) {
return true;
}
// Check with filter
return filteredCurrentItem > 0;
}
});
const itemRef = ref();
const currentElement = useCurrentElement(itemRef);
onMounted(() => {
if (!(currentElement.value instanceof HTMLElement)) return;
// textValue to perform filter
allItems.value.set(id, currentElement.value.textContent ?? props.value?.toString() ?? '');
const groupId = groupContext?.id;
if (groupId) {
if (!allGroups.value.has(groupId)) {
allGroups.value.set(groupId, new Set([id]));
} else {
allGroups.value.get(groupId)?.add(id);
}
}
});
onUnmounted(() => {
allItems.value.delete(id);
});
</script>
<template>
<ListboxItem
v-if="isRender"
v-bind="forwarded"
:id="id"
ref="itemRef"
data-slot="command-item"
:class="
cn(
'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class
)
"
@select="
() => {
filterState.search = '';
}
">
<slot />
</ListboxItem>
</template>

View File

@@ -0,0 +1,26 @@
<script setup>
import { ListboxContent, useForwardProps } from 'reka-ui';
import { cn } from '@/lib/utils';
import { reactiveOmit } from '@vueuse/core';
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false }
});
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardProps(delegatedProps);
</script>
<template>
<ListboxContent
data-slot="command-list"
v-bind="forwarded"
:class="cn('max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto', props.class)">
<div role="presentation">
<slot />
</div>
</ListboxContent>
</template>

View File

@@ -0,0 +1,21 @@
<script setup>
import { Separator } from 'reka-ui';
import { cn } from '@/lib/utils';
import { reactiveOmit } from '@vueuse/core';
const props = defineProps({
orientation: { type: String, required: false },
decorative: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false }
});
const delegatedProps = reactiveOmit(props, 'class');
</script>
<template>
<Separator data-slot="command-separator" v-bind="delegatedProps" :class="cn('bg-border -mx-1 h-px', props.class)">
<slot />
</Separator>
</template>

View File

@@ -0,0 +1,15 @@
<script setup>
import { cn } from '@/lib/utils';
const props = defineProps({
class: { type: null, required: false }
});
</script>
<template>
<span
data-slot="command-shortcut"
:class="cn('text-muted-foreground ml-auto text-xs tracking-widest', props.class)">
<slot />
</span>
</template>

View File

@@ -0,0 +1,18 @@
<script setup>
import { DialogRoot, useForwardPropsEmits } from 'reka-ui';
const props = defineProps({
open: { type: Boolean, required: false },
defaultOpen: { type: Boolean, required: false },
modal: { type: Boolean, required: false }
});
const emits = defineEmits(['update:open']);
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<DialogRoot v-slot="slotProps" data-slot="dialog" v-bind="forwarded">
<slot v-bind="slotProps" />
</DialogRoot>
</template>

View File

@@ -0,0 +1,14 @@
<script setup>
import { DialogClose } from 'reka-ui';
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false }
});
</script>
<template>
<DialogClose data-slot="dialog-close" v-bind="props">
<slot />
</DialogClose>
</template>

View File

@@ -0,0 +1,58 @@
<script setup>
import { DialogClose, DialogContent, DialogPortal, useForwardPropsEmits } from 'reka-ui';
import { X } from 'lucide-vue-next';
import { cn } from '@/lib/utils';
import { reactiveOmit } from '@vueuse/core';
import DialogOverlay from './DialogOverlay.vue';
defineOptions({
inheritAttrs: false
});
const props = defineProps({
forceMount: { type: Boolean, required: false },
disableOutsidePointerEvents: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
showCloseButton: { type: Boolean, required: false, default: true }
});
const emits = defineEmits([
'escapeKeyDown',
'pointerDownOutside',
'focusOutside',
'interactOutside',
'openAutoFocus',
'closeAutoFocus'
]);
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<DialogPortal>
<DialogOverlay />
<DialogContent
data-slot="dialog-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
props.class
)
">
<slot />
<DialogClose
v-if="showCloseButton"
data-slot="dialog-close"
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<X />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogPortal>
</template>

View File

@@ -0,0 +1,24 @@
<script setup>
import { DialogDescription, useForwardProps } from 'reka-ui';
import { cn } from '@/lib/utils';
import { reactiveOmit } from '@vueuse/core';
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false }
});
const delegatedProps = reactiveOmit(props, 'class');
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<DialogDescription
data-slot="dialog-description"
v-bind="forwardedProps"
:class="cn('text-muted-foreground text-sm', props.class)">
<slot />
</DialogDescription>
</template>

View File

@@ -0,0 +1,13 @@
<script setup>
import { cn } from '@/lib/utils';
const props = defineProps({
class: { type: null, required: false }
});
</script>
<template>
<div data-slot="dialog-footer" :class="cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)">
<slot />
</div>
</template>

View File

@@ -0,0 +1,13 @@
<script setup>
import { cn } from '@/lib/utils';
const props = defineProps({
class: { type: null, required: false }
});
</script>
<template>
<div data-slot="dialog-header" :class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)">
<slot />
</div>
</template>

View File

@@ -0,0 +1,28 @@
<script setup>
import { DialogOverlay } from 'reka-ui';
import { cn } from '@/lib/utils';
import { reactiveOmit } from '@vueuse/core';
const props = defineProps({
forceMount: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false }
});
const delegatedProps = reactiveOmit(props, 'class');
</script>
<template>
<DialogOverlay
data-slot="dialog-overlay"
v-bind="delegatedProps"
:class="
cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80',
props.class
)
">
<slot />
</DialogOverlay>
</template>

View File

@@ -0,0 +1,62 @@
<script setup>
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, useForwardPropsEmits } from 'reka-ui';
import { X } from 'lucide-vue-next';
import { cn } from '@/lib/utils';
import { reactiveOmit } from '@vueuse/core';
defineOptions({
inheritAttrs: false
});
const props = defineProps({
forceMount: { type: Boolean, required: false },
disableOutsidePointerEvents: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false }
});
const emits = defineEmits([
'escapeKeyDown',
'pointerDownOutside',
'focusOutside',
'interactOutside',
'openAutoFocus',
'closeAutoFocus'
]);
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0">
<DialogContent
:class="
cn(
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
props.class
)
"
v-bind="{ ...$attrs, ...forwarded }"
@pointer-down-outside="
(event) => {
const originalEvent = event.detail.originalEvent;
const target = originalEvent.target;
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
event.preventDefault();
}
}
">
<slot />
<DialogClose class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary">
<X class="w-4 h-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</template>

View File

@@ -0,0 +1,24 @@
<script setup>
import { DialogTitle, useForwardProps } from 'reka-ui';
import { cn } from '@/lib/utils';
import { reactiveOmit } from '@vueuse/core';
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false }
});
const delegatedProps = reactiveOmit(props, 'class');
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<DialogTitle
data-slot="dialog-title"
v-bind="forwardedProps"
:class="cn('text-lg leading-none font-semibold', props.class)">
<slot />
</DialogTitle>
</template>

View File

@@ -0,0 +1,14 @@
<script setup>
import { DialogTrigger } from 'reka-ui';
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false }
});
</script>
<template>
<DialogTrigger data-slot="dialog-trigger" v-bind="props">
<slot />
</DialogTrigger>
</template>

View File

@@ -0,0 +1,217 @@
<script setup>
import { computed, ref, shallowRef, watch } from 'vue';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useVirtualizer } from '@tanstack/vue-virtual';
const props = defineProps({
modelValue: { type: [String, Number, Array, null], default: null },
groups: { type: Array, required: true },
multiple: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
clearable: { type: Boolean, default: true },
placeholder: { type: String, default: 'Select…' },
searchPlaceholder: { type: String, default: 'Search…' },
searchable: { type: Boolean, default: true },
maxHeight: { type: Number, default: 320 },
itemHeight: { type: Number, default: 40 },
groupHeaderHeight: { type: Number, default: 28 },
closeOnSelect: { type: Boolean, default: false }
});
const emit = defineEmits(['update:modelValue', 'change', 'clear']);
const isOpen = ref(false);
const searchText = ref('');
const scrollContainerRef = shallowRef(null);
const selectedValueSet = computed(() => {
if (props.multiple) {
const values = Array.isArray(props.modelValue) ? props.modelValue : [];
return new Set(values.map(String));
}
return new Set(props.modelValue != null ? [String(props.modelValue)] : []);
});
const virtualListEntries = computed(() => {
const normalizedSearch = props.searchable ? searchText.value.toLowerCase().trim() : '';
const entries = [];
for (const group of props.groups) {
const groupItems = Array.isArray(group.items) ? group.items : [];
const filteredItems = normalizedSearch
? groupItems.filter((item) =>
(item.search ?? item.label ?? '').toLowerCase().includes(normalizedSearch)
)
: groupItems;
if (!filteredItems.length) continue;
entries.push({
type: 'group',
key: `group:${group.key}`,
group
});
for (const item of filteredItems) {
entries.push({
type: 'item',
key: `item:${group.key}:${item.value}`,
group,
item,
value: String(item.value)
});
}
}
return entries;
});
const selectionSummaryText = computed(() => {
const selectedValues = Array.from(selectedValueSet.value);
if (!selectedValues.length) return '';
const valueToLabelMap = new Map();
for (const group of props.groups) {
for (const item of group.items ?? []) {
valueToLabelMap.set(String(item.value), item.label);
}
}
const visibleLabels = selectedValues.slice(0, 3).map((value) => valueToLabelMap.get(value) ?? value);
return selectedValues.length <= 3
? visibleLabels.join(', ')
: `${visibleLabels.join(', ')} +${selectedValues.length - 3}`;
});
function toggleSelection(value) {
if (props.multiple) {
const current = Array.isArray(props.modelValue) ? props.modelValue.map(String) : [];
const next = current.includes(value) ? current.filter((v) => v !== value) : [...current, value];
emit('update:modelValue', next);
emit('change', next);
} else {
emit('update:modelValue', value);
emit('change', value);
if (props.closeOnSelect) {
isOpen.value = false;
}
}
}
function clearSelection() {
const nextValue = props.multiple ? [] : null;
emit('update:modelValue', nextValue);
emit('clear');
emit('change', nextValue);
}
const virtualizer = useVirtualizer(
computed(() => ({
count: virtualListEntries.value.length,
getScrollElement: () => scrollContainerRef.value,
estimateSize: (index) =>
virtualListEntries.value[index]?.type === 'group' ? props.groupHeaderHeight : props.itemHeight,
overscan: 10
}))
);
watch(searchText, () => {
virtualizer.value?.scrollToOffset?.(0);
});
</script>
<template>
<Popover v-model:open="isOpen">
<PopoverTrigger as-child>
<Button variant="outline" size="sm" role="combobox" class="w-full justify-between" :disabled="disabled">
<slot name="trigger" :text="selectionSummaryText" :clear="clearSelection">
<span class="truncate">
{{ selectionSummaryText || placeholder }}
</span>
</slot>
<div class="flex items-center gap-2">
<button
v-if="clearable && selectedValueSet.size && !disabled"
type="button"
class="opacity-60 hover:opacity-90"
@click.stop="clearSelection">
</button>
<span class="opacity-60"></span>
</div>
</Button>
</PopoverTrigger>
<PopoverContent class="w-(--reka-popover-trigger-width) p-2">
<Input v-if="searchable" v-model="searchText" :placeholder="searchPlaceholder" class="mb-2" />
<div
ref="scrollContainerRef"
class="overflow-auto rounded-md border"
:style="{ maxHeight: `${maxHeight}px` }">
<div
:style="{
height: `${virtualizer.getTotalSize()}px`,
position: 'relative'
}">
<template v-for="virtualRow in virtualizer.getVirtualItems()" :key="virtualRow.key">
<div
class="absolute left-0 top-0 w-full"
:style="{ transform: `translateY(${virtualRow.start}px)` }">
<template v-if="virtualListEntries[virtualRow.index]?.type === 'group'">
<div
class="px-2 text-xs font-medium opacity-70 flex items-center"
:style="{ height: `${groupHeaderHeight}px` }">
<slot name="group" :group="virtualListEntries[virtualRow.index].group">
{{ virtualListEntries[virtualRow.index].group.label }}
</slot>
</div>
</template>
<template v-else>
<button
type="button"
class="flex w-full items-center gap-2 px-2 hover:bg-accent text-left"
:style="{ height: `${itemHeight}px` }"
@click="toggleSelection(virtualListEntries[virtualRow.index].value)">
<slot
name="item"
:item="virtualListEntries[virtualRow.index].item"
:selected="selectedValueSet.has(virtualListEntries[virtualRow.index].value)">
<span class="truncate text-sm">
{{ virtualListEntries[virtualRow.index].item.label }}
</span>
<span
v-if="selectedValueSet.has(virtualListEntries[virtualRow.index].value)"
class="ml-auto opacity-70">
</span>
</slot>
</button>
</template>
</div>
</template>
<div v-if="virtualListEntries.length === 0" class="p-3 text-sm opacity-70">
<slot name="empty">Nothing found</slot>
</div>
</div>
</div>
</PopoverContent>
</Popover>
</template>