mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-18 06:13:52 +02:00
combobox dialog components
This commit is contained in:
15
package-lock.json
generated
15
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
102
src/components/ui/command/Command.vue
Normal file
102
src/components/ui/command/Command.vue
Normal 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>
|
||||
35
src/components/ui/command/CommandDialog.vue
Normal file
35
src/components/ui/command/CommandDialog.vue
Normal 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>
|
||||
29
src/components/ui/command/CommandEmpty.vue
Normal file
29
src/components/ui/command/CommandEmpty.vue
Normal 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>
|
||||
47
src/components/ui/command/CommandGroup.vue
Normal file
47
src/components/ui/command/CommandGroup.vue
Normal 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>
|
||||
44
src/components/ui/command/CommandInput.vue
Normal file
44
src/components/ui/command/CommandInput.vue
Normal 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>
|
||||
84
src/components/ui/command/CommandItem.vue
Normal file
84
src/components/ui/command/CommandItem.vue
Normal 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>
|
||||
26
src/components/ui/command/CommandList.vue
Normal file
26
src/components/ui/command/CommandList.vue
Normal 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>
|
||||
21
src/components/ui/command/CommandSeparator.vue
Normal file
21
src/components/ui/command/CommandSeparator.vue
Normal 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>
|
||||
15
src/components/ui/command/CommandShortcut.vue
Normal file
15
src/components/ui/command/CommandShortcut.vue
Normal 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>
|
||||
18
src/components/ui/dialog/Dialog.vue
Normal file
18
src/components/ui/dialog/Dialog.vue
Normal 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>
|
||||
14
src/components/ui/dialog/DialogClose.vue
Normal file
14
src/components/ui/dialog/DialogClose.vue
Normal 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>
|
||||
58
src/components/ui/dialog/DialogContent.vue
Normal file
58
src/components/ui/dialog/DialogContent.vue
Normal 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>
|
||||
24
src/components/ui/dialog/DialogDescription.vue
Normal file
24
src/components/ui/dialog/DialogDescription.vue
Normal 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>
|
||||
13
src/components/ui/dialog/DialogFooter.vue
Normal file
13
src/components/ui/dialog/DialogFooter.vue
Normal 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>
|
||||
13
src/components/ui/dialog/DialogHeader.vue
Normal file
13
src/components/ui/dialog/DialogHeader.vue
Normal 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>
|
||||
28
src/components/ui/dialog/DialogOverlay.vue
Normal file
28
src/components/ui/dialog/DialogOverlay.vue
Normal 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>
|
||||
62
src/components/ui/dialog/DialogScrollContent.vue
Normal file
62
src/components/ui/dialog/DialogScrollContent.vue
Normal 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>
|
||||
24
src/components/ui/dialog/DialogTitle.vue
Normal file
24
src/components/ui/dialog/DialogTitle.vue
Normal 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>
|
||||
14
src/components/ui/dialog/DialogTrigger.vue
Normal file
14
src/components/ui/dialog/DialogTrigger.vue
Normal 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>
|
||||
217
src/components/ui/virtual-combobox/VirtualCombobox.vue
Normal file
217
src/components/ui/virtual-combobox/VirtualCombobox.vue
Normal 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>
|
||||
Reference in New Issue
Block a user