mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-05-07 14:56:06 +02:00
combobox dialog components
This commit is contained in:
Generated
+8
-7
@@ -25,6 +25,7 @@
|
|||||||
"@sentry/vue": "^10.32.1",
|
"@sentry/vue": "^10.32.1",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tanstack/vue-table": "^8.21.3",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
|
"@tanstack/vue-virtual": "^3.13.18",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^25.0.3",
|
"@types/node": "^25.0.3",
|
||||||
"@vitejs/plugin-vue": "^6.0.3",
|
"@vitejs/plugin-vue": "^6.0.3",
|
||||||
@@ -5645,9 +5646,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/virtual-core": {
|
"node_modules/@tanstack/virtual-core": {
|
||||||
"version": "3.13.14",
|
"version": "3.13.18",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.14.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.18.tgz",
|
||||||
"integrity": "sha512-b5Uvd8J2dc7ICeX9SRb/wkCxWk7pUwN214eEPAQsqrsktSKTCmyLxOQWSMgogBByXclZeAdgZ3k4o0fIYUIBqQ==",
|
"integrity": "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
@@ -5676,13 +5677,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/vue-virtual": {
|
"node_modules/@tanstack/vue-virtual": {
|
||||||
"version": "3.13.14",
|
"version": "3.13.18",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.14.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.18.tgz",
|
||||||
"integrity": "sha512-dLKQCWj0uu6Rc1OsTGiClpH75hyf92MvJ9YALAzWdblwImSFnxfXD0mu8yOI7PlxiDAcDA5Pq0Q47YvADAfyfg==",
|
"integrity": "sha512-6pT8HdHtTU5Z+t906cGdCroUNA5wHjFXsNss9gwk7QAr1VNZtz9IQCs2Nhx0gABK48c+OocHl2As+TMg8+Hy4A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/virtual-core": "3.13.14"
|
"@tanstack/virtual-core": "3.13.18"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
"@sentry/vue": "^10.32.1",
|
"@sentry/vue": "^10.32.1",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tanstack/vue-table": "^8.21.3",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
|
"@tanstack/vue-virtual": "^3.13.18",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^25.0.3",
|
"@types/node": "^25.0.3",
|
||||||
"@vitejs/plugin-vue": "^6.0.3",
|
"@vitejs/plugin-vue": "^6.0.3",
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user