mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-13 02:32:15 +01:00
Add virtualizer to ProjectDropdown, ClientDropdown and Reporting
Comboboxes; Remove redundant focus loop on Project/ClientDropdown
This commit is contained in:
@@ -8,8 +8,8 @@ import {
|
||||
ComboboxItem,
|
||||
ComboboxRoot,
|
||||
ComboboxViewport,
|
||||
} from 'radix-vue';
|
||||
import { UseFocusTrap } from '@vueuse/integrations/useFocusTrap/component';
|
||||
ComboboxVirtualizer,
|
||||
} from 'reka-ui';
|
||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||
import { Check, Plus } from '@lucide/vue';
|
||||
|
||||
@@ -26,10 +26,6 @@ const searchInput = ref<HTMLElement | null>(null);
|
||||
const open = ref(false);
|
||||
const searchValue = ref('');
|
||||
|
||||
function isClientSelected(id: string) {
|
||||
return model.value === id;
|
||||
}
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
nextTick(() => {
|
||||
@@ -58,15 +54,23 @@ async function addClientIfNoneExists() {
|
||||
}
|
||||
}
|
||||
|
||||
const NO_CLIENT: { id: string | null; name: string } = { id: null, name: 'No Client' };
|
||||
|
||||
const currentClient = computed(() => {
|
||||
return (
|
||||
props.clients.find((client) => client.id === model.value) ?? {
|
||||
id: null,
|
||||
name: 'No Client',
|
||||
}
|
||||
);
|
||||
return props.clients.find((client) => client.id === model.value) ?? NO_CLIENT;
|
||||
});
|
||||
|
||||
type ClientRow = Client | typeof NO_CLIENT;
|
||||
|
||||
// Fold the "No Client" entry in as the first row so the whole list virtualizes through one
|
||||
// ComboboxVirtualizer. NO_CLIENT is a shared constant so currentClient and the row reference
|
||||
// the same object and single-select highlighting still matches.
|
||||
const clientRows = computed<ClientRow[]>(() => [NO_CLIENT, ...filteredClients.value]);
|
||||
|
||||
function clientRowName(row: ClientRow) {
|
||||
return row.name;
|
||||
}
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'changed']);
|
||||
|
||||
function updateValue(client: { id: string | null; name: string }) {
|
||||
@@ -81,56 +85,51 @@ function updateValue(client: { id: string | null; name: string }) {
|
||||
<slot name="trigger"></slot>
|
||||
</template>
|
||||
<template #content>
|
||||
<UseFocusTrap v-if="open" :options="{ immediate: true, allowOutsideClick: true }">
|
||||
<div v-if="open">
|
||||
<ComboboxRoot
|
||||
v-model:search-term="searchValue"
|
||||
v-model:open="open"
|
||||
:model-value="currentClient"
|
||||
class="relative"
|
||||
:ignore-filter="true"
|
||||
@update:model-value="updateValue">
|
||||
<ComboboxAnchor>
|
||||
<ComboboxInput
|
||||
ref="searchInput"
|
||||
v-model="searchValue"
|
||||
class="bg-transparent border-0 placeholder-muted-foreground text-sm text-popover-foreground py-2 px-3 focus:ring-0 border-b border-popover-border focus:border-popover-border w-full"
|
||||
placeholder="Search for a client..." />
|
||||
</ComboboxAnchor>
|
||||
<ComboboxContent>
|
||||
<ComboboxViewport
|
||||
class="w-[--reka-popper-anchor-width] max-h-60 overflow-y-scroll p-1">
|
||||
<ComboboxItem
|
||||
:value="{ id: null, name: 'No Client' }"
|
||||
class="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground">
|
||||
<span>No Client</span>
|
||||
<span
|
||||
v-if="model === null"
|
||||
class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<Check class="h-4 w-4" />
|
||||
</span>
|
||||
</ComboboxItem>
|
||||
<ComboboxItem
|
||||
v-for="client in filteredClients"
|
||||
:key="client.id"
|
||||
:value="client"
|
||||
class="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
|
||||
:data-client-id="client.id">
|
||||
<span>{{ client.name }}</span>
|
||||
<span
|
||||
v-if="isClientSelected(client.id)"
|
||||
class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<Check class="h-4 w-4" />
|
||||
</span>
|
||||
</ComboboxItem>
|
||||
<div
|
||||
v-if="searchValue.length > 0 && filteredClients.length === 0"
|
||||
class="flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground"
|
||||
@click="addClientIfNoneExists">
|
||||
<Plus class="h-4 w-4 shrink-0" />
|
||||
<span>Add "{{ searchValue }}" as a new Client</span>
|
||||
</div>
|
||||
<ComboboxVirtualizer
|
||||
v-slot="{ option: row }"
|
||||
:options="clientRows"
|
||||
:estimate-size="32"
|
||||
:text-content="clientRowName">
|
||||
<ComboboxItem
|
||||
:value="row"
|
||||
class="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
|
||||
:data-client-id="row.id">
|
||||
<span class="min-w-0 flex-1 truncate">{{ row.name }}</span>
|
||||
<span
|
||||
v-if="model === row.id"
|
||||
class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<Check class="h-4 w-4" />
|
||||
</span>
|
||||
</ComboboxItem>
|
||||
</ComboboxVirtualizer>
|
||||
</ComboboxViewport>
|
||||
<div
|
||||
v-if="searchValue.length > 0 && filteredClients.length === 0"
|
||||
class="flex items-center gap-2 rounded-sm mx-1 px-2 py-1.5 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground"
|
||||
@click="addClientIfNoneExists">
|
||||
<Plus class="h-4 w-4 shrink-0" />
|
||||
<span>Add "{{ searchValue }}" as a new Client</span>
|
||||
</div>
|
||||
</ComboboxContent>
|
||||
</ComboboxRoot>
|
||||
</UseFocusTrap>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
@@ -9,10 +9,16 @@ import {
|
||||
ComboboxItem,
|
||||
ComboboxRoot,
|
||||
ComboboxViewport,
|
||||
} from 'radix-vue';
|
||||
ComboboxVirtualizer,
|
||||
} from 'reka-ui';
|
||||
|
||||
const NONE_ID = 'none';
|
||||
|
||||
// height of one row (px-2 py-1.5 text-sm → 12px padding + 20px line box).
|
||||
// Rows are uniform single-line, so a fixed size is exact enough for the virtualizer and avoids
|
||||
// any per-row DOM measurement.
|
||||
const ROW_HEIGHT = 32;
|
||||
|
||||
const model = defineModel<string[]>({
|
||||
default: [],
|
||||
});
|
||||
@@ -56,6 +62,23 @@ const showNoItem = computed(() => {
|
||||
return props.noItemLabel.toLowerCase().includes(search);
|
||||
});
|
||||
|
||||
// A single flat list for the virtualizer. The optional "no item" entry is folded in as the
|
||||
// first row so the whole list (including it) is virtualized through one ComboboxVirtualizer.
|
||||
type Row = { kind: 'none' } | { kind: 'item'; item: T };
|
||||
|
||||
const rows = computed<Row[]>(() => {
|
||||
const itemRows = filteredItems.value.map((item): Row => ({ kind: 'item', item }));
|
||||
return showNoItem.value ? [{ kind: 'none' }, ...itemRows] : itemRows;
|
||||
});
|
||||
|
||||
function keyForRow(row: Row): string {
|
||||
return row.kind === 'none' ? NONE_ID : props.getKeyFromItem(row.item);
|
||||
}
|
||||
|
||||
function nameForRow(row: Row): string {
|
||||
return row.kind === 'none' ? (props.noItemLabel ?? '') : props.getNameForItem(row.item);
|
||||
}
|
||||
|
||||
function toggleItem(id: string) {
|
||||
if (model.value.includes(id)) {
|
||||
model.value = model.value.filter((itemId) => itemId !== id);
|
||||
@@ -74,46 +97,35 @@ const emit = defineEmits(['update:modelValue', 'changed', 'submit']);
|
||||
<slot name="trigger"></slot>
|
||||
</template>
|
||||
<template #content>
|
||||
<ComboboxRoot
|
||||
v-model:search-term="searchValue"
|
||||
v-model:open="open"
|
||||
class="p-2"
|
||||
:filter-function="(val: string[]) => val">
|
||||
<ComboboxRoot v-model:open="open" class="p-2" :ignore-filter="true">
|
||||
<ComboboxAnchor>
|
||||
<ComboboxInput
|
||||
v-model="searchValue"
|
||||
class="w-full h-8 rounded-md border border-input-border bg-input-background px-3 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none"
|
||||
:placeholder="searchPlaceholder" />
|
||||
</ComboboxAnchor>
|
||||
<ComboboxContent
|
||||
:dismiss-able="false"
|
||||
position="inline"
|
||||
class="mt-2 min-w-60 max-w-80 max-h-60 overflow-y-auto">
|
||||
<ComboboxViewport>
|
||||
<ComboboxItem
|
||||
v-if="showNoItem"
|
||||
:value="NONE_ID"
|
||||
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary data-[highlighted]:bg-card-background-active cursor-default"
|
||||
@select.prevent="toggleItem(NONE_ID)">
|
||||
<Checkbox
|
||||
:checked="model.includes(NONE_ID)"
|
||||
aria-hidden="true"
|
||||
:tabindex="-1"
|
||||
class="pointer-events-none" />
|
||||
<span class="truncate">{{ noItemLabel }}</span>
|
||||
</ComboboxItem>
|
||||
<ComboboxItem
|
||||
v-for="item in filteredItems"
|
||||
:key="getKeyFromItem(item)"
|
||||
:value="getKeyFromItem(item)"
|
||||
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary data-[highlighted]:bg-card-background-active cursor-default"
|
||||
@select.prevent="toggleItem(getKeyFromItem(item))">
|
||||
<Checkbox
|
||||
:checked="model.includes(getKeyFromItem(item))"
|
||||
aria-hidden="true"
|
||||
:tabindex="-1"
|
||||
class="pointer-events-none" />
|
||||
<span class="truncate">{{ getNameForItem(item) }}</span>
|
||||
</ComboboxItem>
|
||||
class="mt-2 min-w-60 max-w-80">
|
||||
<ComboboxViewport class="max-h-60 overflow-y-auto">
|
||||
<ComboboxVirtualizer
|
||||
v-slot="{ option }"
|
||||
:options="rows"
|
||||
:estimate-size="ROW_HEIGHT"
|
||||
:text-content="nameForRow">
|
||||
<ComboboxItem
|
||||
:value="keyForRow(option)"
|
||||
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary data-[highlighted]:bg-card-background-active cursor-default"
|
||||
@select.prevent="toggleItem(keyForRow(option))">
|
||||
<Checkbox
|
||||
:checked="model.includes(keyForRow(option))"
|
||||
aria-hidden="true"
|
||||
:tabindex="-1"
|
||||
class="pointer-events-none" />
|
||||
<span class="truncate">{{ nameForRow(option) }}</span>
|
||||
</ComboboxItem>
|
||||
</ComboboxVirtualizer>
|
||||
</ComboboxViewport>
|
||||
</ComboboxContent>
|
||||
</ComboboxRoot>
|
||||
|
||||
Reference in New Issue
Block a user