Skip to content

Commit 98b0b75

Browse files
Fix Select component ARIA roles and test semantics
1 parent 62a0150 commit 98b0b75

3 files changed

Lines changed: 14 additions & 28 deletions

File tree

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

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,6 @@ import { RemixFormProvider, getValidatedFormData, useRemixForm } from 'remix-hoo
99
import { z } from 'zod';
1010
import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub';
1111

12-
// Helper function to wait for a short time
13-
const waitForSelection = async (ms = 50) => new Promise(resolve => setTimeout(resolve, ms));
14-
1512
const formSchema = z.object({
1613
region: z.string().min(1, 'Please select a region'),
1714
theme: z.string().min(1, 'Please select a theme'),
@@ -236,17 +233,15 @@ Each custom component should use React.forwardRef to preserve focus, ARIA, and k
236233
await userEvent.click(themeSelect);
237234
const purple = await within(document.body).findByRole('option', { name: 'Purple' });
238235
await userEvent.click(purple);
239-
await waitForSelection();
240-
expect(themeSelect).toHaveTextContent('Purple');
236+
await expect(themeSelect).toHaveTextContent('Purple');
241237
});
242238

243239
await step('Open and choose Fruit', async () => {
244240
const fruitSelect = canvas.getByLabelText('Favorite Fruit');
245241
await userEvent.click(fruitSelect);
246242
const banana = await within(document.body).findByRole('option', { name: '🍌 Banana' });
247243
await userEvent.click(banana);
248-
await waitForSelection();
249-
expect(fruitSelect).toHaveTextContent('🍌 Banana');
244+
await expect(fruitSelect).toHaveTextContent('🍌 Banana');
250245
});
251246

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

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

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
import { expect, userEvent, within } from '@storybook/test';
22
import { StoryContext } from '@storybook/react';
33

4-
// Helper function to wait for a short time
5-
const waitForSelection = async (ms = 50) => new Promise(resolve => setTimeout(resolve, ms));
6-
74
// Test selecting a US state
85
export const testUSStateSelection = async ({ canvasElement }: StoryContext) => {
96
const canvas = within(canvasElement);
@@ -16,11 +13,8 @@ export const testUSStateSelection = async ({ canvasElement }: StoryContext) => {
1613
const californiaOption = await within(document.body).findByRole('option', { name: 'California' });
1714
await userEvent.click(californiaOption);
1815

19-
// Wait for the selection to be applied
20-
await waitForSelection();
21-
22-
// Verify the selection
23-
expect(stateDropdown).toHaveTextContent('California');
16+
// Wait for the popover to close and the selection to be applied
17+
await expect(stateDropdown).toHaveTextContent('California');
2418
};
2519

2620
// Test selecting a Canadian province
@@ -35,11 +29,8 @@ export const testCanadaProvinceSelection = async ({ canvasElement }: StoryContex
3529
const ontarioOption = await within(document.body).findByRole('option', { name: 'Ontario' });
3630
await userEvent.click(ontarioOption);
3731

38-
// Wait for the selection to be applied
39-
await waitForSelection();
40-
41-
// Verify the selection
42-
expect(provinceDropdown).toHaveTextContent('Ontario');
32+
// Wait for the popover to close and the selection to be applied
33+
await expect(provinceDropdown).toHaveTextContent('Ontario');
4334
};
4435

4536
// Test form submission
@@ -51,21 +42,21 @@ export const testFormSubmission = async ({ canvasElement }: StoryContext) => {
5142
await userEvent.click(stateDropdown);
5243
const californiaOption = await within(document.body).findByRole('option', { name: 'California' });
5344
await userEvent.click(californiaOption);
54-
await waitForSelection();
45+
await expect(stateDropdown).toHaveTextContent('California');
5546

5647
// Select a province
5748
const provinceDropdown = canvas.getByLabelText('Canadian Province');
5849
await userEvent.click(provinceDropdown);
5950
const ontarioOption = await within(document.body).findByRole('option', { name: 'Ontario' });
6051
await userEvent.click(ontarioOption);
61-
await waitForSelection();
52+
await expect(provinceDropdown).toHaveTextContent('Ontario');
6253

6354
// Select a custom region
6455
const regionDropdown = canvas.getByLabelText('Custom Region');
6556
await userEvent.click(regionDropdown);
6657
const customOption = await within(document.body).findByRole('option', { name: 'New York' });
6758
await userEvent.click(customOption);
68-
await waitForSelection();
59+
await expect(regionDropdown).toHaveTextContent('New York');
6960

7061
// Submit the form
7162
const submitButton = canvas.getByRole('button', { name: 'Submit' });

packages/components/src/ui/select.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export function Select({
5151
const triggerRef = React.useRef<HTMLButtonElement>(null);
5252
const popoverRef = React.useRef<HTMLDivElement>(null);
5353
const selectedItemRef = React.useRef<HTMLButtonElement>(null);
54+
const listboxId = React.useId();
5455
const [menuWidth, setMenuWidth] = React.useState<number | undefined>(undefined);
5556

5657
React.useEffect(() => {
@@ -117,10 +118,10 @@ export function Select({
117118
'placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
118119
className,
119120
)}
120-
// biome-ignore lint/a11y/useAriaPropsForRole: using <button> for PopoverTrigger to ensure keyboard accessibility and focus management
121-
// biome-ignore lint/a11y/useSemanticElements: using <button> for PopoverTrigger to ensure keyboard accessibility and focus management
122121
role="combobox"
122+
aria-expanded={popoverState.isOpen}
123123
aria-haspopup="listbox"
124+
aria-controls={popoverState.isOpen ? listboxId : undefined}
124125
{...buttonProps}
125126
>
126127
{selectedOption?.label || placeholder}
@@ -130,8 +131,8 @@ export function Select({
130131
<PopoverContent
131132
ref={popoverRef}
132133
className={cn('z-50 p-0 shadow-md border-0', contentClassName)}
133-
// biome-ignore lint/a11y/useSemanticElements: using <div> for PopoverContent to ensure keyboard accessibility and focus management
134134
role="listbox"
135+
id={listboxId}
135136
style={{ width: menuWidth ? `${menuWidth}px` : undefined }}
136137
>
137138
<div className="bg-white p-1.5 rounded-md focus:outline-none sm:text-sm">
@@ -185,8 +186,6 @@ export function Select({
185186
isEnterCandidate && 'bg-gray-50',
186187
itemClassName,
187188
)}
188-
// biome-ignore lint/a11y/useSemanticElements: using <button> for PopoverTrigger to ensure keyboard accessibility and focus management
189-
// biome-ignore lint/a11y/useAriaPropsForRole: using <button> for PopoverTrigger to ensure keyboard accessibility and focus management
190189
role="option"
191190
aria-selected={isSelected}
192191
data-selected={isSelected ? 'true' : 'false'}
@@ -206,3 +205,4 @@ export function Select({
206205
</Popover>
207206
);
208207
}
208+

0 commit comments

Comments
 (0)