fix(async-select): fix select loading bugs

This commit is contained in:
2026-06-18 01:24:52 +05:30
parent 463ee0835f
commit d7c4027328
12 changed files with 696 additions and 18 deletions

94
CLAUDE.md Normal file
View File

@@ -0,0 +1,94 @@
<!-- dgc-policy-v11 -->
# Dual-Graph Context Policy
This project uses a local dual-graph MCP server for efficient context retrieval.
## MANDATORY: Adaptive graph_continue rule
**Call `graph_continue` ONLY when you do NOT already know the relevant files.**
### Call `graph_continue` when:
- This is the first message of a new task / conversation
- The task shifts to a completely different area of the codebase
- You need files you haven't read yet in this session
### SKIP `graph_continue` when:
- You already identified the relevant files earlier in this conversation
- You are doing follow-up work on files already read (verify, refactor, test, docs, cleanup, commit)
- The task is pure text (writing a commit message, summarising, explaining)
**If skipping, go directly to `graph_read` on the already-known `file::symbol`.**
## When you DO call graph_continue
1. **If `graph_continue` returns `needs_project=true`**: call `graph_scan` with `pwd`. Do NOT ask the user.
2. **If `graph_continue` returns `skip=true`**: fewer than 5 files - read only specifically named files.
3. **Read `recommended_files`** using `graph_read`.
- Always use `file::symbol` notation (e.g. `src/auth.ts::handleLogin`) - never read whole files.
- `recommended_files` entries that already contain `::` must be passed verbatim.
4. **Obey confidence caps:**
- `confidence=high` -> Stop. Do NOT grep or explore further.
- `confidence=medium` -> `fallback_rg` at most `max_supplementary_greps` times, then `graph_read` at most `max_supplementary_files` more symbols. Stop.
- `confidence=low` -> same as medium. Stop.
## Session State (compact, update after every turn)
Maintain a short JSON block in your working memory. Update it after each turn:
```json
{
"files_identified": ["path/to/file.py"],
"symbols_changed": ["module::function"],
"fix_applied": true,
"features_added": ["description"],
"open_issues": ["one-line note"]
}
```
Use this state - not prose summaries - to remember what's been done across turns.
## Token Usage
A `token-counter` MCP is available for tracking live token usage.
- Before reading a large file: `count_tokens({text: "<content>"})` to check cost first.
- To show running session cost: `get_session_stats()`
- To log completed task: `log_usage({input_tokens: N, output_tokens: N, description: "task"})`
## Rules
- Do NOT use `rg`, `grep`, or bash file exploration before calling `graph_continue` (when required).
- Do NOT do broad/recursive exploration at any confidence level.
- `max_supplementary_greps` and `max_supplementary_files` are hard caps - never exceed them.
- Do NOT call `graph_continue` more than once per turn.
- Always use `file::symbol` notation with `graph_read` - never bare filenames.
- After edits, call `graph_register_edit` with changed files using `file::symbol` notation.
## Context Store
Whenever you make a decision, identify a task, note a next step, fact, or blocker during a conversation, append it to `.dual-graph/context-store.json`.
**Entry format:**
```json
{"type": "decision|task|next|fact|blocker", "content": "one sentence max 15 words", "tags": ["topic"], "files": ["relevant/file.ts"], "date": "YYYY-MM-DD"}
```
**To append:** Read the file -> add the new entry to the array -> Write it back -> call `graph_register_edit` on `.dual-graph/context-store.json`.
**Rules:**
- Only log things worth remembering across sessions (not every minor detail)
- `content` must be under 15 words
- `files` lists the files this decision/task relates to (can be empty)
- Log immediately when the item arises - not at session end
## Session End
When the user signals they are done (e.g. "bye", "done", "wrap up", "end session"), proactively update `CONTEXT.md` in the project root with:
- **Current Task**: one sentence on what was being worked on
- **Key Decisions**: bullet list, max 3 items
- **Next Steps**: bullet list, max 3 items
Keep `CONTEXT.md` under 20 lines total. Do NOT summarize the full conversation - only what's needed to resume next session.

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts"; import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -5,7 +5,7 @@ import type { FieldErrors, UseFormRegister } from 'react-hook-form';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { FormField } from '@/components/form'; import { FormField } from '@/components/form';
import { RoleCombobox } from '@/components/lookups/RoleCombobox'; import { RoleSelect } from '@/components/lookups/RoleSelect';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Dialog, Dialog,
@@ -60,14 +60,17 @@ export function UserSheet({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-xl"> <DialogContent className="flex max-h-[calc(100vh-2rem)] flex-col gap-4 overflow-visible sm:max-w-xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{userId ? 'Edit User' : 'Create User'}</DialogTitle> <DialogTitle>{userId ? 'Edit User' : 'Create User'}</DialogTitle>
<DialogDescription>Assign user details and a role.</DialogDescription> <DialogDescription>Assign user details and a role.</DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4"> <form
onSubmit={onSubmit}
className="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto px-4"
>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<FormField <FormField
id="first-name" id="first-name"
@@ -128,7 +131,7 @@ export function UserSheet({
</div> </div>
<FormField label="Role" required error={roleErrorMessage}> <FormField label="Role" required error={roleErrorMessage}>
<RoleCombobox <RoleSelect
value={roleId} value={roleId}
onValueChange={onRoleChange} onValueChange={onRoleChange}
enabled={open} enabled={open}
@@ -136,8 +139,6 @@ export function UserSheet({
portalContainer={roleComboboxPortalRef} portalContainer={roleComboboxPortalRef}
/> />
<div ref={roleComboboxPortalRef} />
<input <input
type="hidden" type="hidden"
{...register('role_id')} {...register('role_id')}
@@ -165,6 +166,8 @@ export function UserSheet({
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
<div ref={roleComboboxPortalRef} />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@@ -0,0 +1,175 @@
'use client';
import { useCallback, useRef, useState } from 'react';
import { Loader2 } from 'lucide-react';
import type { Virtualizer } from '@tanstack/react-virtual';
import { Button } from '@/components/ui/button';
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxList,
ComboboxTrigger,
ComboboxValue,
} from '@/components/ui/combobox';
import type {
AsyncSelectOption,
AsyncSelectProps,
} from './AsyncSelect.types';
import { VirtualSelectList } from './VirtualSelectList';
type VirtualizerHandle = Virtualizer<HTMLDivElement, Element>;
export function AsyncSelect({
lookup,
onValueChange,
placeholder = 'Select option',
searchPlaceholder = 'Search...',
emptyMessage = 'No items found.',
disabled = false,
portalContainer,
}: AsyncSelectProps) {
const [open, setOpen] = useState(false);
const virtualizerRef = useRef<VirtualizerHandle | null>(null);
const {
options,
selectedOption,
search,
setSearch,
isLoading,
isFetchingNextPage,
isError,
errorMessage,
hasNextPage,
fetchNextPage,
activateList,
} = lookup;
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
setOpen(nextOpen);
if (nextOpen) {
// Kick off the first page only when the dropdown is actually opened.
activateList();
} else {
setSearch('');
}
},
[activateList, setSearch],
);
const handleValueChange = useCallback(
(item: AsyncSelectOption | null) => {
if (item?.value) {
onValueChange(item.value);
}
},
[onValueChange],
);
const handleItemHighlighted = useCallback(
(
_item: AsyncSelectOption | undefined,
event: { reason: string; index: number },
) => {
const virtualizer = virtualizerRef.current;
if (!virtualizer) {
return;
}
const { reason, index } = event;
const isStart = index === 0;
const isEnd = index === virtualizer.options.count - 1;
const shouldScroll =
reason === 'none' || (reason === 'keyboard' && (isStart || isEnd));
if (shouldScroll) {
queueMicrotask(() => {
virtualizer.scrollToIndex(index, { align: isEnd ? 'start' : 'end' });
});
}
},
[],
);
const handleLoadMore = useCallback(() => {
void fetchNextPage();
}, [fetchNextPage]);
const isDisabled = disabled || isLoading;
const triggerPlaceholder = isLoading ? 'Loading...' : placeholder;
return (
<Combobox
virtualized
items={options}
value={selectedOption}
onValueChange={handleValueChange}
open={open}
onOpenChange={handleOpenChange}
inputValue={search}
onInputValueChange={setSearch}
filter={null}
disabled={isDisabled}
itemToStringLabel={(item) => item.label}
isItemEqualToValue={(item, currentValue) =>
item.value === currentValue.value
}
onItemHighlighted={handleItemHighlighted}
>
<ComboboxTrigger
render={
<Button
type="button"
variant="outline"
className="w-full justify-between font-normal"
disabled={isDisabled}
>
{isLoading && !selectedOption ? (
<span className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading...
</span>
) : (
<ComboboxValue placeholder={triggerPlaceholder} />
)}
</Button>
}
/>
<ComboboxContent
container={portalContainer}
className="min-w-[var(--anchor-width)]"
>
<div className="border-b p-2">
<ComboboxInput
showTrigger={false}
placeholder={searchPlaceholder}
disabled={isDisabled}
className="w-full"
/>
</div>
{isError ? (
<div className="px-3 py-2 text-sm text-destructive">
{errorMessage || 'Unable to load options.'}
</div>
) : (
<>
<ComboboxEmpty>{emptyMessage}</ComboboxEmpty>
<ComboboxList className="p-0">
<VirtualSelectList
open={open}
virtualizerRef={virtualizerRef}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onLoadMore={handleLoadMore}
/>
</ComboboxList>
</>
)}
</ComboboxContent>
</Combobox>
);
}

View File

@@ -0,0 +1,51 @@
import type { RefObject } from 'react';
export type AsyncSelectOption = {
value: string;
label: string;
};
export type PaginatedResult<T> = {
items: T[];
total: number;
};
export type PaginatedLookupConfig<T> = {
enabled?: boolean;
pageSize?: number;
debounceMs?: number;
selectedValue?: string;
queryKey: (searchTerm: string) => readonly unknown[];
queryFn: (args: {
searchTerm: string;
skip: number;
limit: number;
}) => Promise<PaginatedResult<T>>;
mapOption: (item: T) => AsyncSelectOption;
resolveSelected?: (value: string) => Promise<T | null>;
resolveSelectedQueryKey?: (value: string) => readonly unknown[];
};
export interface PaginatedLookupState {
options: AsyncSelectOption[];
selectedOption: AsyncSelectOption | null;
search: string;
setSearch: (value: string) => void;
isLoading: boolean;
isFetchingNextPage: boolean;
isError: boolean;
errorMessage?: string;
hasNextPage: boolean;
fetchNextPage: () => void | Promise<unknown>;
activateList: () => void;
}
export interface AsyncSelectProps {
lookup: PaginatedLookupState;
onValueChange: (value: string) => void;
placeholder?: string;
searchPlaceholder?: string;
emptyMessage?: string;
disabled?: boolean;
portalContainer?: RefObject<HTMLDivElement | null>;
}

View File

@@ -0,0 +1,137 @@
'use client';
import {
useCallback,
useEffect,
useImperativeHandle,
useRef,
type RefObject,
} from 'react';
import { Combobox } from '@base-ui/react/combobox';
import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual';
import { Loader2 } from 'lucide-react';
import { ComboboxItem } from '@/components/ui/combobox';
import { cn } from '@/lib/utils';
import type { AsyncSelectOption } from './AsyncSelect.types';
type VirtualizerHandle = Virtualizer<HTMLDivElement, Element>;
const LOAD_MORE_THRESHOLD = 3;
interface VirtualSelectListProps {
open: boolean;
virtualizerRef: RefObject<VirtualizerHandle | null>;
hasNextPage: boolean;
isFetchingNextPage: boolean;
onLoadMore: () => void;
estimateSize?: number;
}
export function VirtualSelectList({
open,
virtualizerRef,
hasNextPage,
isFetchingNextPage,
onLoadMore,
estimateSize = 36,
}: VirtualSelectListProps) {
const filteredItems = Combobox.useFilteredItems<AsyncSelectOption>();
const scrollElementRef = useRef<HTMLDivElement | null>(null);
const itemCount = filteredItems.length + (hasNextPage ? 1 : 0);
const virtualizer = useVirtualizer({
enabled: open,
count: itemCount,
getScrollElement: () => scrollElementRef.current,
estimateSize: () => estimateSize,
overscan: 8,
paddingStart: 4,
paddingEnd: 4,
});
useImperativeHandle(virtualizerRef, () => virtualizer, [virtualizer]);
const handleScrollElementRef = useCallback(
(element: HTMLDivElement | null) => {
scrollElementRef.current = element;
if (element) {
virtualizer.measure();
}
},
[virtualizer],
);
const virtualItems = virtualizer.getVirtualItems();
const totalSize = virtualizer.getTotalSize();
const lastVirtualIndex = virtualItems[virtualItems.length - 1]?.index ?? -1;
useEffect(() => {
if (lastVirtualIndex < 0) {
return;
}
if (
lastVirtualIndex >= filteredItems.length - LOAD_MORE_THRESHOLD &&
hasNextPage &&
!isFetchingNextPage
) {
onLoadMore();
}
}, [
filteredItems.length,
hasNextPage,
isFetchingNextPage,
lastVirtualIndex,
onLoadMore,
]);
if (!filteredItems.length && !hasNextPage) {
return null;
}
return (
<div
ref={handleScrollElementRef}
className={cn(
'max-h-[min(18rem,calc(var(--available-height)-2.25rem))] overflow-y-auto overscroll-contain p-1',
)}
style={{ height: Math.min(totalSize + 8, 200) }}
>
<div className="relative w-full" style={{ height: totalSize }}>
{virtualItems.map((virtualItem) => {
const isLoaderRow = virtualItem.index >= filteredItems.length;
const item = filteredItems[virtualItem.index];
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
className="absolute top-0 left-0 w-full"
style={{
height: virtualItem.size,
transform: `translateY(${virtualItem.start}px)`,
}}
>
{isLoaderRow ? (
<div className="flex items-center justify-center py-2 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
</div>
) : (
<ComboboxItem
value={item}
index={virtualItem.index}
aria-setsize={filteredItems.length}
aria-posinset={virtualItem.index + 1}
>
{item.label}
</ComboboxItem>
)}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,9 @@
export { AsyncSelect } from './AsyncSelect';
export { usePaginatedSelect } from './usePaginatedSelect';
export type {
AsyncSelectProps,
AsyncSelectOption,
PaginatedLookupState,
PaginatedLookupConfig,
PaginatedResult,
} from './AsyncSelect.types';

View File

@@ -0,0 +1,119 @@
'use client';
import { useCallback, useMemo, useState } from 'react';
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import { useDebounce } from '@/hooks/useDebounce';
import type {
AsyncSelectOption,
PaginatedLookupConfig,
} from './AsyncSelect.types';
export function usePaginatedSelect<T>({
enabled = true,
pageSize = 20,
debounceMs = 400,
selectedValue = '',
queryKey,
queryFn,
mapOption,
resolveSelected,
resolveSelectedQueryKey,
}: PaginatedLookupConfig<T>) {
const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search.trim(), debounceMs);
// The list is fetched lazily: only once the dropdown has been opened at
// least once. The selected-value resolution below still runs on `enabled`
// (e.g. dialog open) so the current label always renders.
const [listActivated, setListActivated] = useState(false);
const activateList = useCallback(() => setListActivated(true), []);
const infiniteQuery = useInfiniteQuery({
queryKey: queryKey(debouncedSearch),
queryFn: ({ pageParam }) =>
queryFn({
searchTerm: debouncedSearch,
skip: pageParam,
limit: pageSize,
}),
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) => {
if (lastPage.items.length === 0) {
return undefined;
}
const loadedCount = allPages.reduce(
(count, page) => count + page.items.length,
0,
);
return loadedCount < lastPage.total ? loadedCount : undefined;
},
enabled: enabled && listActivated,
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
});
// `cancelRefetch: false` so a concurrent scroll trigger is ignored rather
// than cancelling the in-flight page and re-requesting the same offset.
const rawFetchNextPage = infiniteQuery.fetchNextPage;
const fetchNextPage = useCallback(
() => rawFetchNextPage({ cancelRefetch: false }),
[rawFetchNextPage],
);
const options = useMemo<AsyncSelectOption[]>(() => {
const items = infiniteQuery.data?.pages.flatMap((page) => page.items) ?? [];
return items.map(mapOption);
}, [infiniteQuery.data?.pages, mapOption]);
const selectedInList = useMemo(
() => options.find((option) => option.value === selectedValue) ?? null,
[options, selectedValue],
);
const resolvedSelectedQuery = useQuery({
queryKey: resolveSelectedQueryKey?.(selectedValue) ?? [
...queryKey(''),
'selected',
selectedValue,
],
queryFn: async () => {
if (!selectedValue || !resolveSelected) {
return null;
}
const item = await resolveSelected(selectedValue);
return item ? mapOption(item) : null;
},
enabled:
enabled &&
Boolean(selectedValue) &&
!selectedInList &&
Boolean(resolveSelected),
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
});
const selectedOption = selectedInList ?? resolvedSelectedQuery.data ?? null;
return {
search,
setSearch,
options,
selectedOption,
isLoading: infiniteQuery.isLoading,
isError: infiniteQuery.isError || resolvedSelectedQuery.isError,
errorMessage:
infiniteQuery.error instanceof Error
? infiniteQuery.error.message
: resolvedSelectedQuery.error instanceof Error
? resolvedSelectedQuery.error.message
: undefined,
isFetchingNextPage: infiniteQuery.isFetchingNextPage,
hasNextPage: infiniteQuery.hasNextPage ?? false,
fetchNextPage,
activateList,
};
}

View File

@@ -39,10 +39,9 @@ const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
canSort ? header.column.getToggleSortingHandler() : undefined canSort ? header.column.getToggleSortingHandler() : undefined
} }
className={cn( className={cn(
'h-10 max-w-0 overflow-hidden bg-card px-2 text-foreground transition-colors', 'h-10 max-w-0 overflow-hidden border-b border-border bg-card px-2 text-foreground transition-colors',
canSort && 'cursor-pointer select-none hover:bg-muted/60', canSort && 'cursor-pointer select-none hover:bg-muted/60',
sortDirection && 'bg-muted/70', sortDirection && 'bg-muted/70',
header.column.getIsPinned() && 'shadow-sm',
)} )}
> >
{header.isPlaceholder ? null : ( {header.isPlaceholder ? null : (

View File

@@ -27,9 +27,14 @@ import { ColumnViewOptions } from './ColumnViewOptions';
import { getPinnedColumnStyles } from './columnStyles'; import { getPinnedColumnStyles } from './columnStyles';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
function shouldTruncateCell(column: { id: string; columnDef: { meta?: unknown } }) { function shouldTruncateCell(column: {
id: string;
columnDef: { meta?: unknown };
}) {
if (column.id === 'actions') return false; if (column.id === 'actions') return false;
const meta = column.columnDef.meta as { disableTruncate?: boolean } | undefined; const meta = column.columnDef.meta as
| { disableTruncate?: boolean }
| undefined;
return !meta?.disableTruncate; return !meta?.disableTruncate;
} }
@@ -181,9 +186,7 @@ function DataTableContent<TData, TValue>({
const columnPinning = React.useMemo<ColumnPinningState>( const columnPinning = React.useMemo<ColumnPinningState>(
() => () =>
actionsSticky && actions?.length actionsSticky && actions?.length ? { [actionsAlign]: ['actions'] } : {},
? { [actionsAlign]: ['actions'] }
: {},
[actions, actionsAlign, actionsSticky], [actions, actionsAlign, actionsSticky],
); );
@@ -281,6 +284,7 @@ function DataTableContent<TData, TValue>({
> >
{row.getVisibleCells().map((cell) => { {row.getVisibleCells().map((cell) => {
const truncate = shouldTruncateCell(cell.column); const truncate = shouldTruncateCell(cell.column);
const pinned = cell.column.getIsPinned();
const content = flexRender( const content = flexRender(
cell.column.columnDef.cell, cell.column.columnDef.cell,
cell.getContext(), cell.getContext(),
@@ -291,8 +295,8 @@ function DataTableContent<TData, TValue>({
key={cell.id} key={cell.id}
style={getPinnedColumnStyles(cell.column)} style={getPinnedColumnStyles(cell.column)}
className={cn( className={cn(
'max-w-0 overflow-hidden', 'max-w-0 overflow-hidden border-b border-border',
cell.column.getIsPinned() && 'bg-card', pinned && 'bg-card',
)} )}
> >
{truncate ? ( {truncate ? (

View File

@@ -0,0 +1,78 @@
'use client';
import { AsyncSelect, usePaginatedSelect } from '@/components/async-select';
import type { AsyncSelectOption } from '@/components/async-select';
import { roleService } from '@/services/api';
import type { Role } from '@/types';
import { roleKeys } from '@/app/(modules)/roles/queries/roleKeys';
import type { RoleSelectProps } from './RoleSelect.types';
const ROLE_PAGE_SIZE = 20;
function parseRoleId(value: string) {
const roleId = Number(value);
return Number.isFinite(roleId) ? roleId : null;
}
function mapRoleOption(role: Role): AsyncSelectOption {
return {
value: String(role.id),
label: role.display_name || role.name,
};
}
export function RoleSelect({
value,
onValueChange,
enabled = true,
disabled = false,
portalContainer,
}: RoleSelectProps) {
const lookup = usePaginatedSelect<Role>({
enabled,
selectedValue: value,
pageSize: ROLE_PAGE_SIZE,
queryKey: (searchTerm) =>
[
...roleKeys.lists(),
'async-lookup',
searchTerm,
ROLE_PAGE_SIZE,
] as const,
queryFn: ({ searchTerm, skip, limit }) =>
roleService.getRoles({
search_term: searchTerm || undefined,
skip,
limit,
effective_status: true,
sort_by: 'display_name',
sort_order: 'asc',
}),
mapOption: mapRoleOption,
resolveSelected: (roleId) => {
const numericRoleId = parseRoleId(roleId);
return numericRoleId
? roleService.getRoleById(numericRoleId)
: Promise.resolve(null);
},
resolveSelectedQueryKey: (roleId) => {
const numericRoleId = parseRoleId(roleId);
return numericRoleId
? roleKeys.detail(numericRoleId)
: [...roleKeys.details(), 'invalid', roleId];
},
});
return (
<AsyncSelect
lookup={lookup}
onValueChange={onValueChange}
disabled={disabled}
placeholder="Select role"
searchPlaceholder="Search roles..."
emptyMessage="No roles found."
portalContainer={portalContainer}
/>
);
}

View File

@@ -0,0 +1,9 @@
import type { RefObject } from 'react';
export interface RoleSelectProps {
value: string;
onValueChange: (value: string) => void;
enabled?: boolean;
disabled?: boolean;
portalContainer?: RefObject<HTMLDivElement | null>;
}