From 9a309be31b6a4c1c504448d4f2f293fa3420f15a Mon Sep 17 00:00:00 2001 From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com> Date: Sun, 21 Sep 2025 20:03:31 +0000 Subject: [PATCH 01/19] feat(select): switch dropdown to shadcn Command (cmdk) primitives and scroll selected into view on open Co-authored-by: Jake Ruesink --- packages/components/src/ui/select.tsx | 180 +++++++++----------------- 1 file changed, 64 insertions(+), 116 deletions(-) diff --git a/packages/components/src/ui/select.tsx b/packages/components/src/ui/select.tsx index 7aeff643..24d967f5 100644 --- a/packages/components/src/ui/select.tsx +++ b/packages/components/src/ui/select.tsx @@ -5,7 +5,7 @@ import * as React from 'react'; import { useOverlayTriggerState } from 'react-stately'; import { PopoverTrigger } from './popover'; import { cn } from './utils'; - +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from './command'; export interface SelectOption { label: string; value: string; @@ -49,56 +49,21 @@ export function Select({ }: SelectProps) { const popoverState = useOverlayTriggerState({}); const listboxId = React.useId(); - const [query, setQuery] = React.useState(''); - const [activeIndex, setActiveIndex] = React.useState(0); - const [isInitialized, setIsInitialized] = React.useState(false); const triggerRef = React.useRef(null); const popoverRef = React.useRef(null); - const selectedItemRef = React.useRef(null); - const listContainerRef = React.useRef(null); + const selectedItemRef = React.useRef(null); // No need for JavaScript width measurement - Radix provides --radix-popover-trigger-width CSS variable - // Scroll to selected item when dropdown opens + // Scroll to selected item when dropdown opens (cmdk also helps with focus/scroll) React.useEffect(() => { - if (popoverState.isOpen && selectedItemRef.current) { - // Use setTimeout to ensure the DOM is fully rendered - setTimeout(() => { - selectedItemRef.current?.scrollIntoView({ block: 'nearest' }); - }, 0); - } + if (!popoverState.isOpen) return; + requestAnimationFrame(() => { + selectedItemRef.current?.scrollIntoView({ block: 'nearest' }); + }); }, [popoverState.isOpen]); const selectedOption = options.find((o) => o.value === value); - const filtered = React.useMemo( - () => (query ? options.filter((o) => `${o.label}`.toLowerCase().includes(query.trim().toLowerCase())) : options), - [options, query], - ); - - // Reset activeIndex when filtered items change or dropdown opens - React.useEffect(() => { - if (popoverState.isOpen) { - setActiveIndex(0); - // Add a small delay to ensure the component is fully initialized - const timer = setTimeout(() => { - setIsInitialized(true); - }, 100); - return () => clearTimeout(timer); - } else { - setIsInitialized(false); - } - }, [popoverState.isOpen]); - - // Scroll active item into view when activeIndex changes - React.useEffect(() => { - if (popoverState.isOpen && listContainerRef.current && filtered.length > 0) { - const activeElement = listContainerRef.current.querySelector(`[data-index="${activeIndex}"]`) as HTMLElement; - if (activeElement) { - activeElement.scrollIntoView({ block: 'nearest' }); - } - } - }, [activeIndex, popoverState.isOpen, filtered.length]); - const Trigger = components?.Trigger || React.forwardRef>((props, ref) => ( @@ -113,13 +78,6 @@ export function Select({ ); Item.displayName = Item.displayName || 'SelectItem'; - const SearchInput = - components?.SearchInput || - React.forwardRef>((props, ref) => ( - - )); - SearchInput.displayName = SearchInput.displayName || 'SelectSearchInput'; - const CheckIcon = components?.CheckIcon || DefaultCheckIcon; const ChevronIcon = components?.ChevronIcon || DefaultChevronIcon; @@ -158,91 +116,81 @@ export function Select({ 'p-0 shadow-md border-0 min-w-[8rem]', contentClassName, )} - role="listbox" - id={listboxId} style={{ width: 'var(--radix-popover-trigger-width)' }} data-slot="popover-content" > -
-
- setQuery(e.target.value)} - placeholder="Search..." - ref={(el) => { - if (el) queueMicrotask(() => el.focus()); - }} - aria-activedescendant={filtered.length > 0 ? `${listboxId}-option-${activeIndex}` : undefined} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - const toSelect = filtered[activeIndex]; - if (toSelect) { - onValueChange?.(toSelect.value); - setQuery(''); - popoverState.close(); - triggerRef.current?.focus(); - } - } else if (e.key === 'Escape') { - e.preventDefault(); - setQuery(''); - popoverState.close(); - triggerRef.current?.focus(); - } else if (e.key === 'ArrowDown') { - e.preventDefault(); - if (filtered.length === 0) return; - setActiveIndex((prev) => Math.min(prev + 1, filtered.length - 1)); - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - if (filtered.length === 0) return; - setActiveIndex((prev) => Math.max(prev - 1, 0)); - } - }} - className="w-full h-9 rounded-md bg-white px-2 text-sm leading-none focus:ring-0 focus:outline-none border-0" - /> + +
+
-
    - {filtered.length === 0 &&
  • No results.
  • } - {filtered.map((option, index) => { - const isSelected = option.value === value; - const isActive = index === activeIndex; - return ( -
  • - { + + No results. + + {options.map((option, index) => { + const isSelected = option.value === value; + const commonProps = { + 'data-selected': isSelected ? 'true' : 'false', + 'data-value': option.value, + 'data-testid': `select-option-${option.value}`, + } as const; + + // When a custom Item is provided, use asChild to let it render as the actual item element + if (components?.Item) { + const CustomItem = Item; + return ( + { + onValueChange?.(option.value); + popoverState.close(); + }} + value={option.label} + aria-selected={isSelected} + id={`${listboxId}-option-${index}`} + {...commonProps} + className={cn(itemClassName)} + ref={isSelected ? selectedItemRef : undefined} + asChild + > + + {isSelected && } + + {option.label} + + + + ); + } + + return ( + { onValueChange?.(option.value); - setQuery(''); popoverState.close(); }} + value={option.label} + aria-selected={isSelected} + id={`${listboxId}-option-${index}`} + {...commonProps} className={cn( 'w-full text-left cursor-pointer select-none py-3 px-3 transition-colors duration-150 flex items-center gap-2 rounded', 'text-gray-900', isSelected ? 'bg-gray-100' : 'hover:bg-gray-100', - isActive && !isSelected && 'bg-gray-50', itemClassName, )} - role="option" - aria-selected={isSelected} - id={`${listboxId}-option-${index}`} - data-selected={isSelected ? 'true' : 'false'} - data-active={isActive ? 'true' : 'false'} - data-index={index} - data-value={option.value} - data-testid={`select-option-${option.value}`} - selected={isSelected} + ref={isSelected ? selectedItemRef : undefined} > {isSelected && } {option.label} - -
  • - ); - })} -
-
+ + ); + })} + + + From 4f25a08db40c91101bf3e6687e50cfcf3bee1a71 Mon Sep 17 00:00:00 2001 From: Jake Ruesink Date: Sun, 21 Sep 2025 19:42:49 -0500 Subject: [PATCH 02/19] 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. --- .vscode/settings.json | 11 +++++- packages/components/src/ui/command.tsx | 11 ++++-- packages/components/src/ui/select.tsx | 55 +++++++++++++++----------- 3 files changed, 48 insertions(+), 29 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index ddbcdfeb..9052de7d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,6 +4,7 @@ "Bazza", "biomejs", "cleanbuild", + "cmdk", "Filenaming", "hookform", "isbot", @@ -23,5 +24,11 @@ "source.fixAll.biome": "explicit", "source.organizeImports.biome": "explicit" }, - "tailwindCSS.classAttributes": ["class", "className", "ngClass", "class:list", "wrapperClassName"] -} + "tailwindCSS.classAttributes": [ + "class", + "className", + "ngClass", + "class:list", + "wrapperClassName" + ] +} \ No newline at end of file diff --git a/packages/components/src/ui/command.tsx b/packages/components/src/ui/command.tsx index 80686eae..8d24e692 100644 --- a/packages/components/src/ui/command.tsx +++ b/packages/components/src/ui/command.tsx @@ -1,7 +1,7 @@ import { Dialog, DialogContent, type DialogProps } from '@radix-ui/react-dialog'; import { Command as CommandPrimitive } from 'cmdk'; -import { Search } from 'lucide-react'; import type * as React from 'react'; +import { forwardRef } from 'react'; import { cn } from './utils'; @@ -32,7 +32,6 @@ const CommandDialog = ({ children, ...props }: CommandDialogProps) => { const CommandInput = ({ className, ...props }: React.ComponentPropsWithoutRef) => (
- ) => ( +const CommandItem = forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( -); +)); CommandItem.displayName = CommandPrimitive.Item.displayName; diff --git a/packages/components/src/ui/select.tsx b/packages/components/src/ui/select.tsx index 24d967f5..4287f683 100644 --- a/packages/components/src/ui/select.tsx +++ b/packages/components/src/ui/select.tsx @@ -1,24 +1,32 @@ import * as PopoverPrimitive from '@radix-ui/react-popover'; import { Popover } from '@radix-ui/react-popover'; import { Check as DefaultCheckIcon, ChevronDown as DefaultChevronIcon } from 'lucide-react'; -import * as React from 'react'; import { useOverlayTriggerState } from 'react-stately'; import { PopoverTrigger } from './popover'; import { cn } from './utils'; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from './command'; +import { + forwardRef, + type Ref, + useEffect, + type ButtonHTMLAttributes, + type ComponentType, + type InputHTMLAttributes, + type RefAttributes, + useId, + useRef, +} from 'react'; export interface SelectOption { label: string; value: string; } export interface SelectUIComponents { - Trigger?: React.ComponentType & React.RefAttributes>; - Item?: React.ComponentType< - React.ButtonHTMLAttributes & { selected?: boolean } & React.RefAttributes - >; - SearchInput?: React.ComponentType< - React.InputHTMLAttributes & React.RefAttributes + Trigger?: ComponentType & RefAttributes>; + Item?: ComponentType< + ButtonHTMLAttributes & { selected?: boolean } & RefAttributes >; + SearchInput?: ComponentType & React.RefAttributes>; CheckIcon?: React.ComponentType>; ChevronIcon?: React.ComponentType>; } @@ -48,17 +56,18 @@ export function Select({ ...buttonProps }: SelectProps) { const popoverState = useOverlayTriggerState({}); - const listboxId = React.useId(); - const triggerRef = React.useRef(null); - const popoverRef = React.useRef(null); - const selectedItemRef = React.useRef(null); + const listboxId = useId(); + const triggerRef = useRef(null); + const popoverRef = useRef(null); + const selectedItemRef = useRef(null); // No need for JavaScript width measurement - Radix provides --radix-popover-trigger-width CSS variable - // Scroll to selected item when dropdown opens (cmdk also helps with focus/scroll) - React.useEffect(() => { + // When opening, ensure the currently selected option is the active item for keyboard nav + useEffect(() => { if (!popoverState.isOpen) return; requestAnimationFrame(() => { - selectedItemRef.current?.scrollIntoView({ block: 'nearest' }); + const selectedEl = selectedItemRef.current as HTMLElement | null; + if (selectedEl) selectedEl.scrollIntoView({ block: 'center' }); }); }, [popoverState.isOpen]); @@ -66,16 +75,16 @@ export function Select({ const Trigger = components?.Trigger || - React.forwardRef>((props, ref) => ( + forwardRef>((props, ref) => (