Skip to content

Commit 869dad8

Browse files
committed
Merge branch 'codegen/lc-321-complete-implementation-guide-adding-scroll-to-error-to' of github.com:lambda-curry/forms into codegen/lc-321-complete-implementation-guide-adding-scroll-to-error-to
2 parents f910d40 + 44e4540 commit 869dad8

File tree

3 files changed

+72
-31
lines changed

3 files changed

+72
-31
lines changed

packages/components/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@lambdacurry/forms",
3-
"version": "0.19.5",
3+
"version": "0.20.0",
44
"type": "module",
55
"main": "./dist/index.js",
66
"types": "./dist/index.d.ts",

packages/components/src/ui/radio-group-item-field.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ const RadioGroupItemField = ({
3131
const LabelComponent = components?.Label || Label;
3232

3333
return (
34-
<div className={cn('flex items-center space-x-2', wrapperClassName)}>
34+
<div className={cn('flex items-center', wrapperClassName)}>
3535
<RadioGroupItemComponent
3636
className={cn(
3737
'h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
@@ -47,7 +47,7 @@ const RadioGroupItemField = ({
4747
<LabelComponent
4848
htmlFor={props.id}
4949
className={cn(
50-
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
50+
'text-sm pl-2 font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
5151
labelClassName,
5252
)}
5353
>

packages/components/src/ui/select.tsx

Lines changed: 69 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { Popover } from '@radix-ui/react-popover';
2+
import * as PopoverPrimitive from '@radix-ui/react-popover';
23
import { Check as DefaultCheckIcon, ChevronDown as DefaultChevronIcon } from 'lucide-react';
34
import * as React from 'react';
45
import { useOverlayTriggerState } from 'react-stately';
5-
import { PopoverContent, PopoverTrigger } from './popover';
6+
import { PopoverTrigger } from './popover';
67
import { cn } from './utils';
78

89
export interface SelectOption {
@@ -49,14 +50,13 @@ export function Select({
4950
const popoverState = useOverlayTriggerState({});
5051
const listboxId = React.useId();
5152
const [query, setQuery] = React.useState('');
53+
const [activeIndex, setActiveIndex] = React.useState(0);
54+
const [isInitialized, setIsInitialized] = React.useState(false);
5255
const triggerRef = React.useRef<HTMLButtonElement>(null);
5356
const popoverRef = React.useRef<HTMLDivElement>(null);
5457
const selectedItemRef = React.useRef<HTMLButtonElement>(null);
55-
const [menuWidth, setMenuWidth] = React.useState<number | undefined>(undefined);
56-
57-
React.useEffect(() => {
58-
if (triggerRef.current) setMenuWidth(triggerRef.current.offsetWidth);
59-
}, []);
58+
const listContainerRef = React.useRef<HTMLUListElement>(null);
59+
// No need for JavaScript width measurement - Radix provides --radix-popover-trigger-width CSS variable
6060

6161
// Scroll to selected item when dropdown opens
6262
React.useEffect(() => {
@@ -75,13 +75,29 @@ export function Select({
7575
[options, query],
7676
);
7777

78-
// Candidate that would be chosen on Enter (exact match else first filtered)
79-
const enterCandidate = React.useMemo(() => {
80-
const q = query.trim().toLowerCase();
81-
if (filtered.length === 0) return undefined;
82-
const exact = q ? filtered.find((o) => o.label.toLowerCase() === q) : undefined;
83-
return exact ?? filtered[0];
84-
}, [filtered, query]);
78+
// Reset activeIndex when filtered items change or dropdown opens
79+
React.useEffect(() => {
80+
if (popoverState.isOpen) {
81+
setActiveIndex(0);
82+
// Add a small delay to ensure the component is fully initialized
83+
const timer = setTimeout(() => {
84+
setIsInitialized(true);
85+
}, 100);
86+
return () => clearTimeout(timer);
87+
} else {
88+
setIsInitialized(false);
89+
}
90+
}, [filtered, popoverState.isOpen]);
91+
92+
// Scroll active item into view when activeIndex changes
93+
React.useEffect(() => {
94+
if (popoverState.isOpen && listContainerRef.current && filtered.length > 0) {
95+
const activeElement = listContainerRef.current.querySelector(`[data-index="${activeIndex}"]`) as HTMLElement;
96+
if (activeElement) {
97+
activeElement.scrollIntoView({ block: 'nearest' });
98+
}
99+
}
100+
}, [activeIndex, popoverState.isOpen, filtered.length]);
85101

86102
const Trigger =
87103
components?.Trigger ||
@@ -130,15 +146,27 @@ export function Select({
130146
<ChevronIcon className="w-4 h-4 opacity-50" />
131147
</Trigger>
132148
</PopoverTrigger>
133-
<PopoverContent
134-
ref={popoverRef}
135-
className={cn('z-50 p-0 shadow-md border-0', contentClassName)}
136-
// biome-ignore lint/a11y/useSemanticElements: using <div> for PopoverContent to ensure keyboard accessibility and focus management
137-
role="listbox"
138-
id={listboxId}
139-
style={{ width: menuWidth ? `${menuWidth}px` : undefined }}
140-
>
141-
<div className="bg-white p-1.5 rounded-md focus:outline-none sm:text-sm">
149+
<PopoverPrimitive.Portal>
150+
<PopoverPrimitive.Content
151+
ref={popoverRef}
152+
align="start"
153+
sideOffset={4}
154+
className={cn(
155+
'z-50 rounded-md border bg-popover text-popover-foreground shadow-md outline-none',
156+
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
157+
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
158+
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
159+
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
160+
'p-0 shadow-md border-0',
161+
contentClassName
162+
)}
163+
// biome-ignore lint/a11y/useSemanticElements: using <div> for PopoverContent to ensure keyboard accessibility and focus management
164+
role="listbox"
165+
id={listboxId}
166+
style={{ width: 'var(--radix-popover-trigger-width)' }}
167+
data-slot="popover-content"
168+
>
169+
<div className="bg-white p-1.5 rounded-md focus:outline-none sm:text-sm w-full">
142170
<div className="px-1.5 pb-1.5">
143171
<SearchInput
144172
type="text"
@@ -148,10 +176,11 @@ export function Select({
148176
ref={(el) => {
149177
if (el) queueMicrotask(() => el.focus());
150178
}}
179+
aria-activedescendant={filtered.length > 0 ? `${listboxId}-option-${activeIndex}` : undefined}
151180
onKeyDown={(e) => {
152181
if (e.key === 'Enter') {
153182
e.preventDefault();
154-
const toSelect = enterCandidate;
183+
const toSelect = filtered[activeIndex];
155184
if (toSelect) {
156185
onValueChange?.(toSelect.value);
157186
setQuery('');
@@ -163,16 +192,24 @@ export function Select({
163192
setQuery('');
164193
popoverState.close();
165194
triggerRef.current?.focus();
195+
} else if (e.key === 'ArrowDown') {
196+
e.preventDefault();
197+
if (filtered.length === 0) return;
198+
setActiveIndex((prev) => Math.min(prev + 1, filtered.length - 1));
199+
} else if (e.key === 'ArrowUp') {
200+
e.preventDefault();
201+
if (filtered.length === 0) return;
202+
setActiveIndex((prev) => Math.max(prev - 1, 0));
166203
}
167204
}}
168205
className="w-full h-9 rounded-md bg-white px-2 text-sm leading-none focus:ring-0 focus:outline-none border-0"
169206
/>
170207
</div>
171-
<ul className="max-h-[200px] overflow-y-auto rounded-md">
208+
<ul ref={listContainerRef} className="max-h-[200px] overflow-y-auto rounded-md w-full">
172209
{filtered.length === 0 && <li className="px-3 py-2 text-sm text-gray-500">No results.</li>}
173-
{filtered.map((option) => {
210+
{filtered.map((option, index) => {
174211
const isSelected = option.value === value;
175-
const isEnterCandidate = query.trim() !== '' && enterCandidate?.value === option.value && !isSelected;
212+
const isActive = index === activeIndex;
176213
return (
177214
<li key={option.value} className="list-none">
178215
<Item
@@ -186,14 +223,17 @@ export function Select({
186223
'w-full text-left cursor-pointer select-none py-3 px-3 transition-colors duration-150 flex items-center gap-2 rounded',
187224
'text-gray-900',
188225
isSelected ? 'bg-gray-100' : 'hover:bg-gray-100',
189-
isEnterCandidate && 'bg-gray-50',
226+
isActive && !isSelected && 'bg-gray-50',
190227
itemClassName,
191228
)}
192229
// biome-ignore lint/a11y/useSemanticElements: using <button> for PopoverTrigger to ensure keyboard accessibility and focus management
193230
// biome-ignore lint/a11y/useAriaPropsForRole: using <button> for PopoverTrigger to ensure keyboard accessibility and focus management
194231
role="option"
195232
aria-selected={isSelected}
233+
id={`${listboxId}-option-${index}`}
196234
data-selected={isSelected ? 'true' : 'false'}
235+
data-active={isActive ? 'true' : 'false'}
236+
data-index={index}
197237
data-value={option.value}
198238
data-testid={`select-option-${option.value}`}
199239
selected={isSelected}
@@ -208,7 +248,8 @@ export function Select({
208248
})}
209249
</ul>
210250
</div>
211-
</PopoverContent>
251+
</PopoverPrimitive.Content>
252+
</PopoverPrimitive.Portal>
212253
</Popover>
213254
);
214255
}

0 commit comments

Comments
 (0)