Skip to content

Commit 4e2a6ae

Browse files
committed
Refactor Select component and related stories for improved accessibility and customization. Added support for dynamic aria attributes and enhanced Storybook tests to verify selection updates. Cleaned up unused region selection code and utilized clsx for class management in custom components.
1 parent 80addbf commit 4e2a6ae

File tree

4 files changed

+54
-55
lines changed

4 files changed

+54
-55
lines changed

apps/docs/src/remix-hook-form/select-custom.stories.tsx

Lines changed: 23 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -3,28 +3,20 @@ import { Select } from '@lambdacurry/forms/remix-hook-form/select';
33
import { Button } from '@lambdacurry/forms/ui/button';
44
import type { Meta, StoryObj } from '@storybook/react-vite';
55
import { expect, userEvent, within } from '@storybook/test';
6+
import clsx from 'clsx';
67
import * as React from 'react';
78
import { type ActionFunctionArgs, useFetcher } from 'react-router';
89
import { RemixFormProvider, getValidatedFormData, useRemixForm } from 'remix-hook-form';
910
import { z } from 'zod';
1011
import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub';
1112

1213
const formSchema = z.object({
13-
region: z.string().min(1, 'Please select a region'),
1414
theme: z.string().min(1, 'Please select a theme'),
1515
fruit: z.string().min(1, 'Please select a fruit'),
1616
});
1717

1818
type FormData = z.infer<typeof formSchema>;
1919

20-
const regionOptions = [
21-
{ label: 'California', value: 'CA' },
22-
{ label: 'Ontario', value: 'ON' },
23-
{ label: 'New York', value: 'NY' },
24-
{ label: 'Quebec', value: 'QC' },
25-
{ label: 'Texas', value: 'TX' },
26-
];
27-
2820
const themeOptions = [
2921
{ label: 'Default', value: 'default' },
3022
{ label: 'Purple', value: 'purple' },
@@ -45,10 +37,10 @@ const PurpleTrigger = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttrib
4537
ref={ref}
4638
type="button"
4739
{...props}
48-
className={
49-
'flex items-center justify-between w-full rounded-md border-2 border-purple-300 bg-purple-50 px-3 py-2 h-10 text-sm text-purple-900 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 ' +
50-
(props.className || '')
51-
}
40+
className={clsx(
41+
'flex items-center justify-between w-full rounded-md border-2 border-purple-300 bg-purple-50 px-3 py-2 h-10 text-sm text-purple-900 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2',
42+
props.className,
43+
)}
5244
/>
5345
),
5446
);
@@ -63,10 +55,10 @@ const PurpleItem = React.forwardRef<
6355
ref={ref}
6456
type="button"
6557
{...props}
66-
className={
67-
'w-full text-left cursor-pointer select-none py-3 px-3 transition-colors duration-150 flex items-center gap-2 rounded text-purple-900 hover:bg-purple-100 data-[selected=true]:bg-purple-100 ' +
68-
(props.className || '')
69-
}
58+
className={clsx(
59+
'w-full text-left cursor-pointer select-none py-3 px-3 transition-colors duration-150 flex items-center gap-2 rounded text-purple-900 hover:bg-purple-100 data-[selected=true]:bg-purple-100',
60+
props.className,
61+
)}
7062
/>
7163
));
7264
PurpleItem.displayName = 'PurpleItem';
@@ -77,7 +69,10 @@ const PurpleSearchInput = React.forwardRef<HTMLInputElement, React.InputHTMLAttr
7769
<input
7870
ref={ref}
7971
{...props}
80-
className={'w-full h-9 rounded-md bg-white px-2 text-sm leading-none border-2 border-purple-200 focus:border-purple-400 focus:outline-none ' + (props.className || '')}
72+
className={clsx(
73+
'w-full h-9 rounded-md bg-white px-2 text-sm leading-none border-2 border-purple-200 focus:border-purple-400 focus:outline-none',
74+
props.className,
75+
)}
8176
/>
8277
),
8378
);
@@ -92,10 +87,10 @@ const GreenItem = React.forwardRef<
9287
ref={ref}
9388
type="button"
9489
{...props}
95-
className={
96-
'w-full text-left cursor-pointer select-none py-3 px-3 transition-colors duration-150 flex items-center gap-2 rounded hover:bg-emerald-100 data-[selected=true]:bg-emerald-100 ' +
97-
(props.className || '')
98-
}
90+
className={clsx(
91+
'w-full text-left cursor-pointer select-none py-3 px-3 transition-colors duration-150 flex items-center gap-2 rounded hover:bg-emerald-100 data-[selected=true]:bg-emerald-100',
92+
props.className,
93+
)}
9994
/>
10095
));
10196
GreenItem.displayName = 'GreenItem';
@@ -106,7 +101,6 @@ const SelectCustomizationExample = () => {
106101
const methods = useRemixForm<FormData>({
107102
resolver: zodResolver(formSchema),
108103
defaultValues: {
109-
region: '',
110104
theme: '',
111105
fruit: '',
112106
},
@@ -118,15 +112,6 @@ const SelectCustomizationExample = () => {
118112
<RemixFormProvider {...methods}>
119113
<fetcher.Form onSubmit={methods.handleSubmit} className="space-y-6">
120114
<div className="grid gap-6 w-[320px]">
121-
{/* Default Select */}
122-
<Select
123-
name="region"
124-
label="Region"
125-
description="Default Select styling"
126-
options={regionOptions}
127-
placeholder="Select a region"
128-
/>
129-
130115
{/* Custom Trigger and Item using components */}
131116
<Select
132117
name="theme"
@@ -231,17 +216,17 @@ Each custom component should use React.forwardRef to preserve focus, ARIA, and k
231216
await step('Open and choose Theme', async () => {
232217
const themeSelect = canvas.getByLabelText('Theme');
233218
await userEvent.click(themeSelect);
234-
const purple = await within(document.body).findByRole('option', { name: 'Purple' });
235-
await userEvent.click(purple);
236-
expect(themeSelect).toHaveTextContent('Purple');
219+
const listbox = await within(document.body).findByRole('listbox');
220+
await userEvent.click(within(listbox).getByRole('option', { name: /Purple/i }));
221+
await expect(canvas.findByRole('combobox', { name: 'Theme' })).resolves.toHaveTextContent('Purple');
237222
});
238223

239224
await step('Open and choose Fruit', async () => {
240225
const fruitSelect = canvas.getByLabelText('Favorite Fruit');
241226
await userEvent.click(fruitSelect);
242-
const banana = await within(document.body).findByRole('option', { name: '🍌 Banana' });
243-
await userEvent.click(banana);
244-
expect(fruitSelect).toHaveTextContent('🍌 Banana');
227+
const listbox = await within(document.body).findByRole('listbox');
228+
await userEvent.click(within(listbox).getByTestId('select-option-banana'));
229+
await expect(canvas.findByRole('combobox', { name: 'Favorite Fruit' })).resolves.toHaveTextContent('Banana');
245230
});
246231

247232
await step('Submit the form', async () => {

apps/docs/src/remix-hook-form/select.stories.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -263,8 +263,8 @@ export const USStateSelection: Story = {
263263
const californiaOption = within(listbox).getByRole('option', { name: 'California' });
264264
await userEvent.click(californiaOption);
265265

266-
// Verify the selection
267-
expect(stateSelect).toHaveTextContent('California');
266+
// Wait for the trigger text to update after portal selection
267+
await expect(canvas.findByRole('combobox', { name: 'US State' })).resolves.toHaveTextContent('California');
268268
});
269269
},
270270
};
@@ -291,8 +291,8 @@ export const CanadaProvinceSelection: Story = {
291291
const ontarioOption = within(listbox).getByRole('option', { name: 'Ontario' });
292292
await userEvent.click(ontarioOption);
293293

294-
// Verify the selection
295-
expect(provinceSelect).toHaveTextContent('Ontario');
294+
// Wait for the trigger text to update after portal selection
295+
await expect(canvas.findByRole('combobox', { name: 'Canadian Province' })).resolves.toHaveTextContent('Ontario');
296296
});
297297
},
298298
};

packages/components/src/ui/form.tsx

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -92,13 +92,24 @@ export interface FormControlProps extends React.ComponentProps<typeof Slot> {
9292
}
9393

9494
export function FormControl({ Component, ...props }: FormControlProps) {
95-
const { formItemId, formDescriptionId, formMessageId, error, ...restProps } = props;
95+
const context = React.useContext(FormItemContext);
96+
const {
97+
formItemId: fromPropsId,
98+
formDescriptionId: fromPropsDesc,
99+
formMessageId: fromPropsMsg,
100+
error,
101+
...restProps
102+
} = props;
103+
104+
const computedId = fromPropsId ?? context.formItemId;
105+
const computedDescriptionId = fromPropsDesc ?? context.formDescriptionId;
106+
const computedMessageId = fromPropsMsg ?? context.formMessageId;
96107

97108
const ariaProps = {
98-
id: formItemId,
99-
'aria-describedby': error ? `${formDescriptionId} ${formMessageId}` : formDescriptionId,
109+
id: computedId,
110+
'aria-describedby': error ? `${computedDescriptionId} ${computedMessageId}` : computedDescriptionId,
100111
'aria-invalid': !!error,
101-
};
112+
} as const;
102113

103114
if (Component) {
104115
return <Component {...restProps} {...ariaProps} />;
@@ -135,17 +146,14 @@ export interface FormMessageProps extends React.HTMLAttributes<HTMLParagraphElem
135146
Component?: React.ComponentType<FormMessageProps>;
136147
}
137148

138-
export function FormMessage({
139-
Component,
140-
className,
141-
formMessageId,
142-
error,
143-
children,
144-
...rest
145-
}: FormMessageProps) {
149+
export function FormMessage({ Component, className, formMessageId, error, children, ...rest }: FormMessageProps) {
146150
if (Component) {
147151
// Ensure custom props do not leak to DOM by not spreading them
148-
return <Component id={formMessageId} className={className} error={error} {...rest}>{children}</Component>;
152+
return (
153+
<Component id={formMessageId} className={className} error={error} {...rest}>
154+
{children}
155+
</Component>
156+
);
149157
}
150158

151159
const body = error ? error : children;

packages/components/src/ui/select.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export function Select({
4747
...buttonProps
4848
}: SelectProps) {
4949
const popoverState = useOverlayTriggerState({});
50+
const listboxId = React.useId();
5051
const [query, setQuery] = React.useState('');
5152
const triggerRef = React.useRef<HTMLButtonElement>(null);
5253
const popoverRef = React.useRef<HTMLDivElement>(null);
@@ -121,6 +122,8 @@ export function Select({
121122
// biome-ignore lint/a11y/useSemanticElements: using <button> for PopoverTrigger to ensure keyboard accessibility and focus management
122123
role="combobox"
123124
aria-haspopup="listbox"
125+
aria-expanded={popoverState.isOpen}
126+
aria-controls={listboxId}
124127
{...buttonProps}
125128
>
126129
{selectedOption?.label || placeholder}
@@ -132,6 +135,7 @@ export function Select({
132135
className={cn('z-50 p-0 shadow-md border-0', contentClassName)}
133136
// biome-ignore lint/a11y/useSemanticElements: using <div> for PopoverContent to ensure keyboard accessibility and focus management
134137
role="listbox"
138+
id={listboxId}
135139
style={{ width: menuWidth ? `${menuWidth}px` : undefined }}
136140
>
137141
<div className="bg-white p-1.5 rounded-md focus:outline-none sm:text-sm">
@@ -190,6 +194,8 @@ export function Select({
190194
role="option"
191195
aria-selected={isSelected}
192196
data-selected={isSelected ? 'true' : 'false'}
197+
data-value={option.value}
198+
data-testid={`select-option-${option.value}`}
193199
selected={isSelected}
194200
>
195201
{isSelected && <CheckIcon className="h-4 w-4 flex-shrink-0" />}

0 commit comments

Comments
 (0)