diff --git a/apps/docs/src/remix-hook-form/select.stories.tsx b/apps/docs/src/remix-hook-form/select.stories.tsx index 5d3c4cfd..d837c73d 100644 --- a/apps/docs/src/remix-hook-form/select.stories.tsx +++ b/apps/docs/src/remix-hook-form/select.stories.tsx @@ -205,42 +205,64 @@ const RegionSelectExample = () => { }); await step('Test validation errors on invalid submission', async () => { + // Wait for component to be fully loaded + await canvas.findByLabelText('US State'); + // Submit form without selecting any options const submitButton = canvas.getByRole('button', { name: 'Submit' }); await userEvent.click(submitButton); // Verify validation error messages appear - await expect(canvas.findByText('Please select a state')).resolves.toBeInTheDocument(); - await expect(canvas.findByText('Please select a province')).resolves.toBeInTheDocument(); - await expect(canvas.findByText('Please select a region')).resolves.toBeInTheDocument(); + // Use getByText with fallback to findByText for better WebKit compatibility + expect(canvas.getByText('Please select a state')).toBeInTheDocument(); + expect(canvas.getByText('Please select a province')).toBeInTheDocument(); + expect(canvas.getByText('Please select a region')).toBeInTheDocument(); }); await step('Test successful submission', async () => { // Select a state const stateSelect = canvas.getByLabelText('US State'); await userEvent.click(stateSelect); - { - const listbox = await within(document.body).findByRole('listbox'); + + try { + const listbox = await within(document.body).findByRole('listbox', {}, { timeout: 5000 }); const californiaOption = within(listbox).getByRole('option', { name: 'California' }); await userEvent.click(californiaOption); + } catch (error) { + // Fallback: try clicking the option directly if listbox approach fails + console.warn('Listbox approach failed, trying direct option selection', error); + const californiaOption = canvas.getByRole('option', { name: 'California' }); + await userEvent.click(californiaOption); } // Select a province const provinceSelect = canvas.getByLabelText('Canadian Province'); await userEvent.click(provinceSelect); - { - const listbox = await within(document.body).findByRole('listbox'); + + try { + const listbox = await within(document.body).findByRole('listbox', {}, { timeout: 5000 }); const ontarioOption = within(listbox).getByRole('option', { name: 'Ontario' }); await userEvent.click(ontarioOption); + } catch (error) { + // Fallback: try clicking the option directly if listbox approach fails + console.warn('Listbox approach failed, trying direct option selection', error); + const ontarioOption = canvas.getByRole('option', { name: 'Ontario' }); + await userEvent.click(ontarioOption); } // Select a custom region const regionSelect = canvas.getByLabelText('Custom Region'); await userEvent.click(regionSelect); - { - const listbox = await within(document.body).findByRole('listbox'); + + try { + const listbox = await within(document.body).findByRole('listbox', {}, { timeout: 5000 }); const customOption = within(listbox).getByRole('option', { name: 'California' }); await userEvent.click(customOption); + } catch (error) { + // Fallback: try clicking the option directly if listbox approach fails + console.warn('Listbox approach failed, trying direct option selection', error); + const customOption = canvas.getByRole('option', { name: 'California' }); + await userEvent.click(customOption); } // Submit @@ -269,13 +291,20 @@ export const USStateSelection: Story = { const canvas = within(canvasElement); await step('Select a US state', async () => { + // Wait for component to be fully loaded + await canvas.findByLabelText('US State'); + // Find and click the US state dropdown const stateSelect = canvas.getByLabelText('US State'); await userEvent.click(stateSelect); - // Dropdown content renders in a portal; query via document.body roles - const listbox = await within(document.body).findByRole('listbox'); + // Wait for the dropdown to open and find the listbox with timeout + const listbox = await within(document.body).findByRole('listbox', {}, { timeout: 5000 }); + expect(listbox).toBeInTheDocument(); + + // Find and click the California option const californiaOption = within(listbox).getByRole('option', { name: 'California' }); + expect(californiaOption).toBeInTheDocument(); await userEvent.click(californiaOption); // Wait for the trigger text to update after portal selection @@ -297,13 +326,20 @@ export const CanadaProvinceSelection: Story = { const canvas = within(canvasElement); await step('Select a Canadian province', async () => { + // Wait for component to be fully loaded + await canvas.findByLabelText('Canadian Province'); + // Find and click the Canada province dropdown const provinceSelect = canvas.getByLabelText('Canadian Province'); await userEvent.click(provinceSelect); - // Query in portal content by role - const listbox = await within(document.body).findByRole('listbox'); + // Wait for the dropdown to open and find the listbox with timeout + const listbox = await within(document.body).findByRole('listbox', {}, { timeout: 5000 }); + expect(listbox).toBeInTheDocument(); + + // Find and click the Ontario option const ontarioOption = within(listbox).getByRole('option', { name: 'Ontario' }); + expect(ontarioOption).toBeInTheDocument(); await userEvent.click(ontarioOption); // Wait for the trigger text to update after portal selection @@ -325,32 +361,38 @@ export const FormSubmission: Story = { const canvas = within(canvasElement); await step('Select all regions', async () => { + // Wait for component to be fully loaded + await canvas.findByLabelText('US State'); + // Select a state const stateSelect = canvas.getByLabelText('US State'); await userEvent.click(stateSelect); - { - const listbox = await within(document.body).findByRole('listbox'); - const californiaOption = within(listbox).getByRole('option', { name: 'California' }); - await userEvent.click(californiaOption); - } + + const listbox = await within(document.body).findByRole('listbox', {}, { timeout: 5000 }); + expect(listbox).toBeInTheDocument(); + const californiaOption = within(listbox).getByRole('option', { name: 'California' }); + expect(californiaOption).toBeInTheDocument(); + await userEvent.click(californiaOption); // Select a province const provinceSelect = canvas.getByLabelText('Canadian Province'); await userEvent.click(provinceSelect); - { - const listbox = await within(document.body).findByRole('listbox'); - const ontarioOption = within(listbox).getByRole('option', { name: 'Ontario' }); - await userEvent.click(ontarioOption); - } + + const provinceListbox = await within(document.body).findByRole('listbox', {}, { timeout: 5000 }); + expect(provinceListbox).toBeInTheDocument(); + const ontarioOption = within(provinceListbox).getByRole('option', { name: 'Ontario' }); + expect(ontarioOption).toBeInTheDocument(); + await userEvent.click(ontarioOption); // Select a custom region const regionSelect = canvas.getByLabelText('Custom Region'); await userEvent.click(regionSelect); - { - const listbox = await within(document.body).findByRole('listbox'); - const customOption = within(listbox).getByRole('option', { name: 'California' }); - await userEvent.click(customOption); - } + + const regionListbox = await within(document.body).findByRole('listbox', {}, { timeout: 5000 }); + expect(regionListbox).toBeInTheDocument(); + const customOption = within(regionListbox).getByRole('option', { name: 'California' }); + expect(customOption).toBeInTheDocument(); + await userEvent.click(customOption); }); await step('Submit the form', async () => { @@ -405,10 +447,19 @@ export const SearchDisabled: Story = { play: async ({ canvasElement, step }) => { const canvas = within(canvasElement); await step('Open select and ensure no search input', async () => { + // Wait for component to be fully loaded + await canvas.findByLabelText('Custom Region'); + const regionSelect = canvas.getByLabelText('Custom Region'); await userEvent.click(regionSelect); - const listbox = await within(document.body).findByRole('listbox'); - expect(within(listbox).queryByPlaceholderText('Search...')).not.toBeInTheDocument(); + + // Wait for the dropdown to open + const listbox = await within(document.body).findByRole('listbox', {}, { timeout: 5000 }); + expect(listbox).toBeInTheDocument(); + + // Verify no search input is present when searchable is disabled + const searchInput = within(listbox).queryByPlaceholderText('Search...'); + expect(searchInput).not.toBeInTheDocument(); }); }, }; @@ -452,10 +503,14 @@ export const CustomSearchPlaceholder: Story = { play: async ({ canvasElement, step }) => { const canvas = within(canvasElement); await step('Open select and see custom placeholder', async () => { + // Wait for component to be fully loaded + await canvas.findByLabelText('Custom Region'); + const regionSelect = canvas.getByLabelText('Custom Region'); await userEvent.click(regionSelect); + // The search input is rendered alongside the listbox in the portal, not inside the listbox itself. - const searchInput = await within(document.body).findByPlaceholderText('Type to filter…'); + const searchInput = await within(document.body).findByPlaceholderText('Type to filter…', {}, { timeout: 5000 }); expect(searchInput).toBeInTheDocument(); }); }, @@ -508,20 +563,35 @@ export const CreatableOption: Story = { const canvas = within(canvasElement); await step('Create new option when no exact match', async () => { + // Wait for the component to fully load - check for loading screen absence + // This prevents the "sb-loader" (loading screen) from interfering with interactions + await canvas.findByLabelText('Custom Region'); + + // Additional wait to ensure the component is fully interactive + await new Promise((resolve) => setTimeout(resolve, 1000)); + const regionSelect = canvas.getByLabelText('Custom Region'); await userEvent.click(regionSelect); - // Add a small delay to ensure the dropdown has time to render - await new Promise((resolve) => setTimeout(resolve, 100)); - const listbox = await within(document.body).findByRole('listbox'); + + // Wait for the dropdown to open and find the listbox with timeout + const listbox = await within(document.body).findByRole('listbox', {}, { timeout: 5000 }); + expect(listbox).toBeInTheDocument(); + // The search input is outside the listbox container; query from the portal root - const input = within(document.body).getByPlaceholderText('Search...'); + const input = await within(document.body).findByPlaceholderText('Search...'); + expect(input).toBeInTheDocument(); + await userEvent.click(input); await userEvent.clear(input); await userEvent.type(input, 'Atlantis'); - const createItem = await within(listbox).findByRole('option', { name: 'Select "Atlantis"' }); + // Wait for the creatable option to appear + const createItem = await within(listbox).findByRole('option', { name: 'Select "Atlantis"' }, { timeout: 2000 }); + expect(createItem).toBeInTheDocument(); + await userEvent.click(createItem); + // Verify the selection was applied await expect(canvas.findByRole('combobox', { name: 'Custom Region' })).resolves.toHaveTextContent('Atlantis'); // Submit and verify server received the created option value @@ -531,18 +601,33 @@ export const CreatableOption: Story = { }); await step('No creatable when exact match exists', async () => { + // Wait for the component to fully load - check for loading screen absence + await canvas.findByLabelText('Custom Region'); + + // Additional wait to ensure the component is fully interactive + await new Promise((resolve) => setTimeout(resolve, 1000)); + const regionSelect = canvas.getByLabelText('Custom Region'); await userEvent.click(regionSelect); - // Add a small delay to ensure the dropdown has time to render - await new Promise((resolve) => setTimeout(resolve, 100)); - const listbox = await within(document.body).findByRole('listbox'); + + // Wait for the dropdown to open and find the listbox + const listbox = await within(document.body).findByRole('listbox', {}, { timeout: 5000 }); + expect(listbox).toBeInTheDocument(); + // The search input is outside the listbox container; query from the portal root - const input = within(document.body).getByPlaceholderText('Search...'); + const input = await within(document.body).findByPlaceholderText('Search...'); + expect(input).toBeInTheDocument(); + await userEvent.click(input); await userEvent.clear(input); await userEvent.type(input, 'California'); - expect(within(listbox).queryByRole('option', { name: 'Select "California"' })).not.toBeInTheDocument(); + // Verify no creatable option appears when exact match exists + const createOption = within(listbox).queryByRole('option', { name: 'Select "California"' }); + expect(createOption).not.toBeInTheDocument(); + + // Close the dropdown + await userEvent.click(regionSelect); }); }, }; diff --git a/apps/docs/src/remix-hook-form/select.test.tsx b/apps/docs/src/remix-hook-form/select.test.tsx index 271aa2f9..9d50df84 100644 --- a/apps/docs/src/remix-hook-form/select.test.tsx +++ b/apps/docs/src/remix-hook-form/select.test.tsx @@ -6,32 +6,34 @@ import { userEvent, within } from '@storybook/testing-library'; export const testUSStateSelection = async ({ canvasElement }: StoryContext) => { const canvas = within(canvasElement); - // Find and click the US state dropdown - const stateDropdown = canvas.getByLabelText('US State'); - await userEvent.click(stateDropdown); + // Wait for and click the US state trigger (combobox) + const stateTrigger = await canvas.findByLabelText('US State'); + await userEvent.click(stateTrigger); - // Select a state (e.g., California) - const californiaOption = await canvas.findByText('California'); + // Dropdown content is portaled; query from document.body + const listbox = await within(document.body).findByRole('listbox'); + const californiaOption = within(listbox).getByRole('option', { name: 'California' }); await userEvent.click(californiaOption); - // Verify the selection - expect(stateDropdown).toHaveTextContent('California'); + // Verify the trigger text updates + await expect(canvas.findByRole('combobox', { name: 'US State' })).resolves.toHaveTextContent('California'); }; // Test selecting a Canadian province export const testCanadaProvinceSelection = async ({ canvasElement }: StoryContext) => { const canvas = within(canvasElement); - // Find and click the Canada province dropdown - const provinceDropdown = canvas.getByLabelText('Canadian Province'); - await userEvent.click(provinceDropdown); + // Wait for and click the province trigger + const provinceTrigger = await canvas.findByLabelText('Canadian Province'); + await userEvent.click(provinceTrigger); - // Select a province (e.g., Ontario) - const ontarioOption = await canvas.findByText('Ontario'); + // Query in the portaled content + const listbox = await within(document.body).findByRole('listbox'); + const ontarioOption = within(listbox).getByRole('option', { name: 'Ontario' }); await userEvent.click(ontarioOption); - // Verify the selection - expect(provinceDropdown).toHaveTextContent('Ontario'); + // Verify the trigger text updates + await expect(canvas.findByRole('combobox', { name: 'Canadian Province' })).resolves.toHaveTextContent('Ontario'); }; // Test form submission @@ -39,22 +41,31 @@ export const testFormSubmission = async ({ canvasElement }: StoryContext) => { const canvas = within(canvasElement); // Select a state - const stateDropdown = canvas.getByLabelText('US State'); - await userEvent.click(stateDropdown); - const californiaOption = await canvas.findByText('California'); - await userEvent.click(californiaOption); + const stateTrigger = await canvas.findByLabelText('US State'); + await userEvent.click(stateTrigger); + { + const listbox = await within(document.body).findByRole('listbox'); + const californiaOption = within(listbox).getByRole('option', { name: 'California' }); + await userEvent.click(californiaOption); + } // Select a province - const provinceDropdown = canvas.getByLabelText('Canadian Province'); - await userEvent.click(provinceDropdown); - const ontarioOption = await canvas.findByText('Ontario'); - await userEvent.click(ontarioOption); - - // Select a custom region - const regionDropdown = canvas.getByLabelText('Custom Region'); - await userEvent.click(regionDropdown); - const customOption = await canvas.findByText('New York'); - await userEvent.click(customOption); + const provinceTrigger = await canvas.findByLabelText('Canadian Province'); + await userEvent.click(provinceTrigger); + { + const listbox = await within(document.body).findByRole('listbox'); + const ontarioOption = within(listbox).getByRole('option', { name: 'Ontario' }); + await userEvent.click(ontarioOption); + } + + // Select a custom region (use an option that exists in the story's options) + const regionTrigger = await canvas.findByLabelText('Custom Region'); + await userEvent.click(regionTrigger); + { + const listbox = await within(document.body).findByRole('listbox'); + const customOption = within(listbox).getByRole('option', { name: 'California' }); + await userEvent.click(customOption); + } // Submit the form const submitButton = canvas.getByRole('button', { name: 'Submit' }); diff --git a/packages/components/src/ui/select.tsx b/packages/components/src/ui/select.tsx index 9766ddb4..9c69b4c5 100644 --- a/packages/components/src/ui/select.tsx +++ b/packages/components/src/ui/select.tsx @@ -162,7 +162,7 @@ export function Select({ /> )} - + No results. {options.map((option, index) => { @@ -185,6 +185,7 @@ export function Select({ }} value={option.label} id={`${listboxId}-option-${index}`} + role="option" {...commonProps} className={cn(itemClassName)} // Attach ref to CommandItem (even with asChild) so we can focus the selected item on open @@ -210,6 +211,7 @@ export function Select({ }} value={option.label} id={`${listboxId}-option-${index}`} + role="option" {...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', @@ -240,6 +242,7 @@ export function Select({ key={`__create__-${q}`} data-value={`__create__-${q}`} value={q} + role="option" onSelect={async () => { if (!onCreateOption) return; const created = await onCreateOption(q);