Skip to content

Commit 4f25a08

Browse files
committed
feat: integrate cmdk for Command components and enhance Select with forwardRef support
- Added cmdk as a dependency and updated Command components to utilize it. - Refactored Select component to use forwardRef for Trigger and Item, improving ref handling. - Adjusted scrolling behavior for selected items in the dropdown to enhance accessibility.
1 parent 9a309be commit 4f25a08

File tree

3 files changed

+48
-29
lines changed

3 files changed

+48
-29
lines changed

.vscode/settings.json

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"Bazza",
55
"biomejs",
66
"cleanbuild",
7+
"cmdk",
78
"Filenaming",
89
"hookform",
910
"isbot",
@@ -23,5 +24,11 @@
2324
"source.fixAll.biome": "explicit",
2425
"source.organizeImports.biome": "explicit"
2526
},
26-
"tailwindCSS.classAttributes": ["class", "className", "ngClass", "class:list", "wrapperClassName"]
27-
}
27+
"tailwindCSS.classAttributes": [
28+
"class",
29+
"className",
30+
"ngClass",
31+
"class:list",
32+
"wrapperClassName"
33+
]
34+
}

packages/components/src/ui/command.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Dialog, DialogContent, type DialogProps } from '@radix-ui/react-dialog';
22
import { Command as CommandPrimitive } from 'cmdk';
3-
import { Search } from 'lucide-react';
43
import type * as React from 'react';
4+
import { forwardRef } from 'react';
55

66
import { cn } from './utils';
77

@@ -32,7 +32,6 @@ const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
3232

3333
const CommandInput = ({ className, ...props }: React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>) => (
3434
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
35-
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
3635
<CommandPrimitive.Input
3736
className={cn(
3837
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50',
@@ -78,15 +77,19 @@ const CommandSeparator = ({
7877

7978
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
8079

81-
const CommandItem = ({ className, ...props }: React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>) => (
80+
const CommandItem = forwardRef<
81+
React.ComponentRef<typeof CommandPrimitive.Item>,
82+
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
83+
>(({ className, ...props }, ref) => (
8284
<CommandPrimitive.Item
85+
ref={ref}
8386
className={cn(
8487
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50',
8588
className,
8689
)}
8790
{...props}
8891
/>
89-
);
92+
));
9093

9194
CommandItem.displayName = CommandPrimitive.Item.displayName;
9295

packages/components/src/ui/select.tsx

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,32 @@
11
import * as PopoverPrimitive from '@radix-ui/react-popover';
22
import { Popover } from '@radix-ui/react-popover';
33
import { Check as DefaultCheckIcon, ChevronDown as DefaultChevronIcon } from 'lucide-react';
4-
import * as React from 'react';
54
import { useOverlayTriggerState } from 'react-stately';
65
import { PopoverTrigger } from './popover';
76
import { cn } from './utils';
87
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from './command';
8+
import {
9+
forwardRef,
10+
type Ref,
11+
useEffect,
12+
type ButtonHTMLAttributes,
13+
type ComponentType,
14+
type InputHTMLAttributes,
15+
type RefAttributes,
16+
useId,
17+
useRef,
18+
} from 'react';
919
export interface SelectOption {
1020
label: string;
1121
value: string;
1222
}
1323

1424
export interface SelectUIComponents {
15-
Trigger?: React.ComponentType<React.ButtonHTMLAttributes<HTMLButtonElement> & React.RefAttributes<HTMLButtonElement>>;
16-
Item?: React.ComponentType<
17-
React.ButtonHTMLAttributes<HTMLButtonElement> & { selected?: boolean } & React.RefAttributes<HTMLButtonElement>
18-
>;
19-
SearchInput?: React.ComponentType<
20-
React.InputHTMLAttributes<HTMLInputElement> & React.RefAttributes<HTMLInputElement>
25+
Trigger?: ComponentType<ButtonHTMLAttributes<HTMLButtonElement> & RefAttributes<HTMLButtonElement>>;
26+
Item?: ComponentType<
27+
ButtonHTMLAttributes<HTMLButtonElement> & { selected?: boolean } & RefAttributes<HTMLButtonElement>
2128
>;
29+
SearchInput?: ComponentType<InputHTMLAttributes<HTMLInputElement> & React.RefAttributes<HTMLInputElement>>;
2230
CheckIcon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
2331
ChevronIcon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
2432
}
@@ -48,34 +56,35 @@ export function Select({
4856
...buttonProps
4957
}: SelectProps) {
5058
const popoverState = useOverlayTriggerState({});
51-
const listboxId = React.useId();
52-
const triggerRef = React.useRef<HTMLButtonElement>(null);
53-
const popoverRef = React.useRef<HTMLDivElement>(null);
54-
const selectedItemRef = React.useRef<HTMLElement>(null);
59+
const listboxId = useId();
60+
const triggerRef = useRef<HTMLButtonElement>(null);
61+
const popoverRef = useRef<HTMLDivElement>(null);
62+
const selectedItemRef = useRef<HTMLElement>(null);
5563
// No need for JavaScript width measurement - Radix provides --radix-popover-trigger-width CSS variable
5664

57-
// Scroll to selected item when dropdown opens (cmdk also helps with focus/scroll)
58-
React.useEffect(() => {
65+
// When opening, ensure the currently selected option is the active item for keyboard nav
66+
useEffect(() => {
5967
if (!popoverState.isOpen) return;
6068
requestAnimationFrame(() => {
61-
selectedItemRef.current?.scrollIntoView({ block: 'nearest' });
69+
const selectedEl = selectedItemRef.current as HTMLElement | null;
70+
if (selectedEl) selectedEl.scrollIntoView({ block: 'center' });
6271
});
6372
}, [popoverState.isOpen]);
6473

6574
const selectedOption = options.find((o) => o.value === value);
6675

6776
const Trigger =
6877
components?.Trigger ||
69-
React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>((props, ref) => (
78+
forwardRef<HTMLButtonElement, ButtonHTMLAttributes<HTMLButtonElement>>((props, ref) => (
7079
<button ref={ref} type="button" {...props} />
7180
));
7281
Trigger.displayName = Trigger.displayName || 'SelectTrigger';
7382

7483
const Item =
7584
components?.Item ||
76-
React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement> & { selected?: boolean }>(
77-
(props, ref) => <button ref={ref} type="button" {...props} />,
78-
);
85+
forwardRef<HTMLButtonElement, ButtonHTMLAttributes<HTMLButtonElement> & { selected?: boolean }>((props, ref) => (
86+
<button ref={ref} type="button" {...props} />
87+
));
7988
Item.displayName = Item.displayName || 'SelectItem';
8089

8190
const CheckIcon = components?.CheckIcon || DefaultCheckIcon;
@@ -113,7 +122,7 @@ export function Select({
113122
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
114123
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
115124
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
116-
'p-0 shadow-md border-0 min-w-[8rem]',
125+
'p-0 shadow-md border-0 min-w-2xs',
117126
contentClassName,
118127
)}
119128
style={{ width: 'var(--radix-popover-trigger-width)' }}
@@ -145,11 +154,11 @@ export function Select({
145154
popoverState.close();
146155
}}
147156
value={option.label}
148-
aria-selected={isSelected}
149157
id={`${listboxId}-option-${index}`}
150158
{...commonProps}
151159
className={cn(itemClassName)}
152-
ref={isSelected ? selectedItemRef : undefined}
160+
// Attach ref to CommandItem (even with asChild) so we can focus the selected item on open
161+
ref={isSelected ? (selectedItemRef as unknown as Ref<HTMLDivElement>) : undefined}
153162
asChild
154163
>
155164
<CustomItem selected={isSelected}>
@@ -170,7 +179,6 @@ export function Select({
170179
popoverState.close();
171180
}}
172181
value={option.label}
173-
aria-selected={isSelected}
174182
id={`${listboxId}-option-${index}`}
175183
{...commonProps}
176184
className={cn(
@@ -179,7 +187,8 @@ export function Select({
179187
isSelected ? 'bg-gray-100' : 'hover:bg-gray-100',
180188
itemClassName,
181189
)}
182-
ref={isSelected ? selectedItemRef : undefined}
190+
// Ensure we can programmatically focus the selected item when opening
191+
ref={isSelected ? (selectedItemRef as unknown as Ref<HTMLDivElement>) : undefined}
183192
>
184193
{isSelected && <CheckIcon className="h-4 w-4 flex-shrink-0" />}
185194
<span className={cn('block truncate', !isSelected && 'ml-6', isSelected && 'font-semibold')}>

0 commit comments

Comments
 (0)