From 2322d781aa356ecc72d6bdbd6a177b29bddb3c7b Mon Sep 17 00:00:00 2001 From: Lin Latt Date: Wed, 11 Nov 2026 22:24:28 -0500 Subject: [PATCH 1/4] fix(Select): guard empty results and retain modal popup scope --- src/components/Select/Select.stories.tsx | 2 +- src/components/Select/Select.test.tsx | 34 ++++++++++++++++++++++++ src/components/Select/Select.tsx | 16 +++++++---- 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/components/Select/Select.stories.tsx b/src/components/Select/Select.stories.tsx index 14c28203b..26fb42f5d 100644 --- a/src/components/Select/Select.stories.tsx +++ b/src/components/Select/Select.stories.tsx @@ -194,7 +194,7 @@ const [specialty, setSpecialty] = useState(''); - Accessibility: trigger is \` + + + + ); + await user.click(screen.getByRole('combobox', { name: 'Category' })); + expect(screen.getByRole('alertdialog')).toContainElement( + screen.getByRole('textbox', { name: 'Search options' }) + ); + }); + it('keeps searchable popups inside the containing modal dialog', async () => { const user = userEvent.setup(); renderWithTheme( diff --git a/src/components/Select/Select.tsx b/src/components/Select/Select.tsx index 06b348a0c..2c3d5551b 100644 --- a/src/components/Select/Select.tsx +++ b/src/components/Select/Select.tsx @@ -675,8 +675,16 @@ function Select(props: SelectProps) { ref={(node) => { dropdownRef.current = node; floatingRef.current = node; + // The top layer keeps viewport positioning valid inside transformed dialogs. + node?.showPopover?.(); }} - style={dropdownStyle} + popover={ + typeof HTMLElement !== 'undefined' && + 'showPopover' in HTMLElement.prototype + ? 'manual' + : undefined + } + style={{ inset: 'auto', margin: 0, padding: 0, ...dropdownStyle }} className={cn( 'border-border bg-card rounded-lg border shadow-lg', 'flex flex-col overflow-hidden', @@ -781,8 +789,9 @@ function Select(props: SelectProps) { )} , - triggerRef.current?.closest('[role="dialog"], dialog') ?? - document.body + triggerRef.current?.closest( + '[role="dialog"], [role="alertdialog"], dialog' + ) ?? document.body )} diff --git a/src/hooks/useFocusTrap.ts b/src/hooks/useFocusTrap.ts index ecd08d44f..57449d3ce 100644 --- a/src/hooks/useFocusTrap.ts +++ b/src/hooks/useFocusTrap.ts @@ -31,21 +31,22 @@ export function useFocusTrap( if (!enabled || !containerRef.current || isStorybookDocsMode()) return; const container = containerRef.current; - const focusableElements = container.querySelectorAll( - 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' - ); - - if (focusableElements.length === 0) return; - - const firstElement = focusableElements[0]; - const lastElement = focusableElements[focusableElements.length - 1]; + const getFocusableElements = () => + container.querySelectorAll( + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' + ); // Focus the first element when trap is enabled - firstElement.focus(); + getFocusableElements()[0]?.focus(); const handleKeyDown = (event: globalThis.KeyboardEvent) => { if (event.key !== 'Tab') return; + const focusableElements = getFocusableElements(); + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + if (!firstElement || !lastElement) return; + if (event.shiftKey) { // Shift + Tab if (document.activeElement === firstElement) { diff --git a/tests/visual/components.spec.ts b/tests/visual/components.spec.ts index 958e42064..8f725c6ed 100644 --- a/tests/visual/components.spec.ts +++ b/tests/visual/components.spec.ts @@ -258,7 +258,10 @@ test.describe('Visual Regression Tests - Core Components', () => { test('RichMediaCaptureEditor - Attach file round-trips into markdown via uploadHandler', async ({ page, }) => { - await gotoStory(page, 'editors-richmediacaptureeditor--with-upload-handler'); + await gotoStory( + page, + 'editors-richmediacaptureeditor--with-upload-handler' + ); // Insertion happens at the caret, exactly like paste — put it in the // paragraph (markdown headings cannot carry images through serialization). await page @@ -266,13 +269,11 @@ test.describe('Visual Regression Tests - Core Components', () => { .click(); // The unrestricted hidden input backs "Attach file" (the other is // accept="image/*" for Capture image). - await page - .locator('input[type="file"]:not([accept])') - .setInputFiles({ - name: 'note-photo.png', - mimeType: 'image/png', - buffer: Buffer.from('fake-png-bytes'), - }); + await page.locator('input[type="file"]:not([accept])').setInputFiles({ + name: 'note-photo.png', + mimeType: 'image/png', + buffer: Buffer.from('fake-png-bytes'), + }); await expect(page.getByRole('status')).toHaveText('Attached', { timeout: 15000, }); @@ -1212,6 +1213,41 @@ test.describe('Visual Regression Tests - Core Components', () => { await expect(page).toHaveScreenshot('select-default.png'); }); + test('Select - Search stays anchored inside a transformed dialog', async ({ + page, + }) => { + await gotoStory(page, 'choice-inputs-select--searchable'); + const trigger = page.getByRole('combobox').first(); + await trigger.evaluate((element) => { + const wrapper = element.closest( + '[data-slot="select-wrapper"]' + )!; + wrapper.setAttribute('role', 'dialog'); + wrapper.setAttribute('aria-modal', 'true'); + wrapper.style.transform = 'translate(80px, 40px) scale(0.9)'; + }); + await trigger.click(); + const popup = page.locator('[data-slot="select-dropdown"]'); + await expect( + page.getByRole('dialog').getByRole('textbox', { name: 'Search options' }) + ).toBeVisible(); + await expect( + page.getByRole('textbox', { name: 'Search options' }) + ).toBeFocused(); + await expect + .poll(async () => { + const anchor = await trigger.boundingBox(); + const menu = await popup.boundingBox(); + if (!anchor || !menu) return Infinity; + return Math.max( + Math.abs(menu.x - anchor.x), + Math.abs(menu.y - anchor.y - anchor.height - 4), + Math.abs(menu.width - anchor.width) + ); + }) + .toBeLessThan(2); + }); + test('CountryDropdown - Default (empty placeholder)', async ({ page }) => { await gotoStory(page, 'choice-inputs-countrydropdown--default'); await expect(page).toHaveScreenshot('countrydropdown-default.png'); From 47739bff41c5d6d26cccb8edfe632c2cd67d59d4 Mon Sep 17 00:00:00 2001 From: Lin Latt Date: Wed, 11 Nov 2026 22:53:49 -0500 Subject: [PATCH 3/4] fix(Select): preserve dark contrast and announce active options --- src/components/Select/Select.test.tsx | 55 +++++++++++++++++++++++++++ src/components/Select/Select.tsx | 15 +++++++- tests/visual/components.spec.ts | 15 ++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/src/components/Select/Select.test.tsx b/src/components/Select/Select.test.tsx index 15d0ade3a..8d5893ae3 100644 --- a/src/components/Select/Select.test.tsx +++ b/src/components/Select/Select.test.tsx @@ -139,6 +139,61 @@ describe('Select portals', () => { }); describe('Select typeahead', () => { + it.each([false, true])( + 'announces the active option from the focused control (searchable: %s)', + async (searchable) => { + const user = userEvent.setup(); + renderWithTheme( +