Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/components/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@lambdacurry/forms",
"version": "0.22.2",
"version": "0.22.3",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
Expand Down
29 changes: 15 additions & 14 deletions packages/components/src/ui/select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ import {
useId,
useRef,
} from 'react';
export interface SelectOption {
export interface SelectOption<T extends React.Key = string> {
label: string;
value: string;
value: T;
}

export interface SelectUIComponents {
Expand All @@ -34,10 +34,11 @@ export interface SelectUIComponents {

export type SelectContentProps = Pick<ComponentProps<typeof PopoverPrimitive.Content>, 'align' | 'side' | 'sideOffset'>;

export interface SelectProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'value' | 'onChange'> {
options: SelectOption[];
value?: string;
onValueChange?: (value: string) => void;
export interface SelectProps<T extends React.Key = string>
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'value' | 'onChange'> {
options: SelectOption<T>[];
value?: T;
onValueChange?: (value: T) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
Expand All @@ -50,7 +51,7 @@ export interface SelectProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonE
searchInputProps?: React.ComponentPropsWithoutRef<typeof CommandInput>;
// Creatable behavior
creatable?: boolean;
onCreateOption?: (input: string) => SelectOption | Promise<SelectOption>;
onCreateOption?: (input: string) => SelectOption<T> | Promise<SelectOption<T>>;
createOptionLabel?: (input: string) => string;
}

Expand All @@ -60,7 +61,7 @@ const DefaultSearchInput = forwardRef<HTMLInputElement, React.ComponentPropsWith
);
DefaultSearchInput.displayName = 'SelectSearchInput';

export function Select({
export function Select<T extends React.Key = string>({
options,
value,
onValueChange,
Expand All @@ -77,7 +78,7 @@ export function Select({
onCreateOption,
createOptionLabel,
...buttonProps
}: SelectProps) {
}: SelectProps<T>) {
const popoverState = useOverlayTriggerState({});
const listboxId = useId();
const triggerRef = useRef<HTMLButtonElement>(null);
Expand Down Expand Up @@ -132,7 +133,7 @@ export function Select({
aria-controls={listboxId}
{...buttonProps}
>
{value !== '' ? (selectedOption?.label ?? value) : placeholder}
{value != null && value !== '' ? (selectedOption?.label ?? String(value)) : placeholder}
<ChevronIcon className="w-4 h-4 opacity-50" />
</Trigger>
</PopoverTrigger>
Expand Down Expand Up @@ -175,8 +176,8 @@ export function Select({
const isSelected = option.value === value;
const commonProps = {
'data-selected': isSelected ? 'true' : 'false',
'data-value': option.value,
'data-testid': `select-option-${option.value}`,
'data-value': String(option.value),
'data-testid': `select-option-${String(option.value)}`,
} as const;

// When a custom Item is provided, use asChild to let it render as the actual item element
Expand Down Expand Up @@ -240,7 +241,7 @@ export function Select({
const lower = q.toLowerCase();
const hasExactMatch =
q.length > 0 &&
options.some((o) => o.label.toLowerCase() === lower || o.value.toLowerCase() === lower);
options.some((o) => o.label.toLowerCase() === lower || String(o.value).toLowerCase() === lower);
if (!creatable || !q || hasExactMatch) return null;
Comment on lines +244 to 245
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Creatable exact-match can suppress “Create” while showing no results

You added exact-match against String(o.value), but CommandItem search still uses only label, so typing a value that matches an existing option’s value (but not its label) produces “No results” and also hides the create option.

Two fixes (pick one):

  • Include value in the item’s search string:
    • Change each CommandItem value prop to include both: value={${option.label} ${String(option.value)}}
  • If supported by your Command/CommandItem, add keywords with the stringified value:
    • keywords={[String(option.value)]}

Example (outside the changed hunk):

// For both branches where CommandItem is rendered
<CommandItem
  ...
- value={option.label}
+ value={`${option.label} ${String(option.value)}`}
  ...
>

This aligns the exact-match guard with the visible search results and avoids a confusing “No results” state.

🤖 Prompt for AI Agents
In packages/components/src/ui/select.tsx around lines 244-245, the exact-match
check compares the query to String(o.value) but CommandItem search only uses the
label, causing a hidden "Create" when a value (not label) matches; fix by making
CommandItem searchable on both label and value or by adding keywords: either set
each CommandItem value to include both label and stringified value
(value={`${option.label} ${String(option.value)}`}) or, if supported, add
keywords={[String(option.value)]} to the CommandItem props so the visible search
results and the creatable exact-match guard stay in sync.

const label = createOptionLabel?.(q) ?? `Select "${q}"`;
return (
Expand All @@ -252,7 +253,7 @@ export function Select({
onSelect={async () => {
if (!onCreateOption) return;
const created = await onCreateOption(q);
if (created?.value) onValueChange?.(created.value);
onValueChange?.(created.value);
popoverState.close();
}}
className={cn(
Expand Down