From 9bd8c80b3627adc55d28e24b15473c9ec0d1bc63 Mon Sep 17 00:00:00 2001 From: Srividya Ramaswamy Date: Mon, 26 Oct 2026 18:34:05 -0700 Subject: [PATCH 1/3] test(dropdown): cover multiselection ownership and native reentry Exercise DRP-005 and DRP-011 action/no-op matrices and author DRP-007 recent-selection/fallback native focus cases with honest physical-input capability gates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b66f3ef-85ac-4405-bd35-dcc167a077b1 --- .changeset/metal-plums-sniff.md | 5 + .../components/dropdown/dropdown.stories.tsx | 57 +++++++++++ .../src/components/dropdown/dropdown.test.tsx | 94 +++++++++++++++++++ 3 files changed, 156 insertions(+) create mode 100644 .changeset/metal-plums-sniff.md diff --git a/.changeset/metal-plums-sniff.md b/.changeset/metal-plums-sniff.md new file mode 100644 index 0000000000..064435dc68 --- /dev/null +++ b/.changeset/metal-plums-sniff.md @@ -0,0 +1,5 @@ +--- + +--- + + diff --git a/packages/agentic/components/src/components/dropdown/dropdown.stories.tsx b/packages/agentic/components/src/components/dropdown/dropdown.stories.tsx index b04a1d0ebe..4efb3cf086 100644 --- a/packages/agentic/components/src/components/dropdown/dropdown.stories.tsx +++ b/packages/agentic/components/src/components/dropdown/dropdown.stories.tsx @@ -120,6 +120,13 @@ type Story = WdioStory>; export const Default: Story = { tags: ['desktop-focus'], wdio: { + 'DRP-011 exposes a named, empty, collapsed native combobox before input': async ({ browser, expect }) => { + const trigger = await browser.$('~dropdown-trigger'); + expect(await trigger.getProperty('role')).toBe('combobox'); + expect(await trigger.getProperty('name')).toBe('Fruit'); + expect(await trigger.getProperty('expanded')).toBe(false); + expect(await trigger.getProperty('value')).toBe(''); + }, 'opens from the keyboard on the first option and commits with Enter': async (context) => { const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); if (!requireDesktopFocus(context)) return; @@ -235,6 +242,56 @@ export const Multiple: Story = { docs: { description: { story: 'Activation toggles options and keeps the list open; the trigger summarizes long selections.' } }, }, wdio: { + 'DRP-007 reopens on the most recently toggled selected option rather than the Up edge': async (context) => { + const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { inWindowWith } = await import('../menu/menu.wdio.ts'); + const { browser, expect } = context; + await (await import('./dropdown.wdio.ts')).openDropdownFromKeyboard(context, 'Apple'); + await browser.keys('\uE007'); + await expect(await browser.$('~dropdown-selection')).toHaveText('Apple'); + await browser.keys('\uE015'); + await expect(await browser.$('~dropdown-focus')).toHaveText('Banana'); + await browser.keys('\uE007'); + await expect(await browser.$('~dropdown-selection')).toHaveText('Apple, Banana'); + await browser.keys('\uE00C'); + await expect(await browser.$('~dropdown-reason')).toHaveText('Closed: dismiss'); + await expectNativeState(browser, 'dropdown-trigger', 'focused', true); + + await browser.keys('\uE013'); + await expect(await browser.$('~dropdown-reason')).toHaveText('Opened: trigger'); + await expect(await browser.$('~dropdown-focus')).toHaveText('Banana'); + expect(await (await browser.$('~dropdown-trigger')).getProperty('value')).toBe('Apple, Banana'); + await inWindowWith(context, 'dropdown-banana', async () => { + await expectNativeState(browser, 'dropdown-banana', 'focused', true); + }); + }, + 'DRP-007 falls back to the first selected option after the latest option is deselected': async (context) => { + const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { inWindowWith } = await import('../menu/menu.wdio.ts'); + const { browser, expect } = context; + await (await import('./dropdown.wdio.ts')).openDropdownFromKeyboard(context, 'Apple'); + await browser.keys('\uE007'); + await expect(await browser.$('~dropdown-selection')).toHaveText('Apple'); + await browser.keys('\uE015'); + await expect(await browser.$('~dropdown-focus')).toHaveText('Banana'); + await browser.keys('\uE007'); + await expect(await browser.$('~dropdown-selection')).toHaveText('Apple, Banana'); + await browser.keys('\uE007'); + await expect(await browser.$('~dropdown-selection')).toHaveText('Apple'); + await browser.keys('\uE00C'); + await expect(await browser.$('~dropdown-reason')).toHaveText('Closed: dismiss'); + await expectNativeState(browser, 'dropdown-trigger', 'focused', true); + + await browser.keys('\uE013'); + await expect(await browser.$('~dropdown-reason')).toHaveText('Opened: trigger'); + await expect(await browser.$('~dropdown-focus')).toHaveText('Apple'); + expect(await (await browser.$('~dropdown-trigger')).getProperty('value')).toBe('Apple'); + await inWindowWith(context, 'dropdown-apple', async () => { + await expectNativeState(browser, 'dropdown-apple', 'focused', true); + }); + }, 'toggles options and stays open until dismissed': async (context) => { const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); if (!requireDesktopFocus(context)) return; diff --git a/packages/agentic/components/src/components/dropdown/dropdown.test.tsx b/packages/agentic/components/src/components/dropdown/dropdown.test.tsx index a30176dba9..5af86eee47 100644 --- a/packages/agentic/components/src/components/dropdown/dropdown.test.tsx +++ b/packages/agentic/components/src/components/dropdown/dropdown.test.tsx @@ -68,6 +68,100 @@ function Scene(props: Partial) { describe('Dropdown', () => { beforeEach(() => dispatchCommand.mockClear()); + it.each( + (['controlled', 'uncontrolled'] as const).flatMap((owner) => + [ + { scenario: 'select replaces', actionName: 'Select', initial: ['apple'], target: 'cherry', next: ['cherry'], changed: true }, + { scenario: 'select unchanged', actionName: 'Select', initial: ['apple'], target: 'apple', next: ['apple'], changed: false }, + { + scenario: 'add appends', + actionName: 'AddToSelection', + initial: ['apple'], + target: 'cherry', + next: ['apple', 'cherry'], + changed: true, + }, + { + scenario: 'add existing', + actionName: 'AddToSelection', + initial: ['apple', 'cherry'], + target: 'apple', + next: ['apple', 'cherry'], + changed: false, + }, + { scenario: 'remove selected', actionName: 'RemoveFromSelection', initial: ['apple'], target: 'apple', next: [], changed: true }, + { + scenario: 'remove absent', + actionName: 'RemoveFromSelection', + initial: ['apple'], + target: 'cherry', + next: ['apple'], + changed: false, + }, + { scenario: 'custom forwarded', actionName: 'custom', initial: ['apple'], target: 'cherry', next: ['apple'], changed: false }, + ].map((scenario) => ({ owner, ...scenario })), + ), + )('DRP-005 DRP-011 handles $scenario once with $owner multiselection', async ({ owner, actionName, initial, target, next, changed }) => { + const calls: string[] = []; + const onSelectedOptionsChange = jest.fn(() => calls.push('selection')); + const onAccessibilityAction = jest.fn(() => calls.push('action')); + const onOpenChange = jest.fn(); + const onPress = jest.fn(); + const scene = (selection: string[]) => ( + + {['apple', 'cherry'].map((value) => ( + + ))} + + ); + const component = await render(scene(initial)); + const event = action(actionName); + await fireEvent(component.getByRole('listitem', { name: target === 'apple' ? 'Apple' : 'Cherry' }), 'accessibilityAction', event); + + expect(onSelectedOptionsChange.mock.calls).toEqual(changed ? [[next, { value: target }]] : []); + expect(onAccessibilityAction.mock.calls).toEqual([[event]]); + expect(calls).toEqual(changed ? ['selection', 'action'] : ['action']); + expect(onPress).not.toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalled(); + expect(component.getByRole('combobox', { name: 'Fruit' }).props.accessibilityState.expanded).toBe(true); + const displayed = owner === 'controlled' ? initial : next; + expect(component.getAllByRole('listitem').map((option) => option.props.accessibilityState.selected)).toEqual([ + displayed.includes('apple'), + displayed.includes('cherry'), + ]); + + await component.rerender(scene(next)); + const trigger = component.getByRole('combobox', { name: 'Fruit' }); + expect(trigger.props.accessibilityValue).toEqual({ + text: ['apple', 'cherry'] + .filter((value) => next.includes(value)) + .map((value) => (value === 'apple' ? 'Apple' : 'Cherry')) + .join(', '), + }); + expect(component.getAllByRole('listitem').map((option) => option.props.accessibilityState.selected)).toEqual([ + next.includes('apple'), + next.includes('cherry'), + ]); + expect(onSelectedOptionsChange).toHaveBeenCalledTimes(changed ? 1 : 0); + expect(onAccessibilityAction).toHaveBeenCalledTimes(1); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + it('renders an inaccessible root around a collapsed combobox trigger showing the placeholder', async () => { const ref = React.createRef(); await render(); From e38356b92e8a59eade3e4b23afa6e6e4799325e2 Mon Sep 17 00:00:00 2001 From: Srividya Ramaswamy Date: Tue, 27 Oct 2026 15:05:14 -0700 Subject: [PATCH 2/3] Scope dropdown coverage to unit and non-input native cases Preserve deferred cases on dropdown-input-tests-held. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b66f3ef-85ac-4405-bd35-dcc167a077b1 --- .../components/dropdown/dropdown.stories.tsx | 50 ------------------- 1 file changed, 50 deletions(-) diff --git a/packages/agentic/components/src/components/dropdown/dropdown.stories.tsx b/packages/agentic/components/src/components/dropdown/dropdown.stories.tsx index 4efb3cf086..8e016e4c54 100644 --- a/packages/agentic/components/src/components/dropdown/dropdown.stories.tsx +++ b/packages/agentic/components/src/components/dropdown/dropdown.stories.tsx @@ -242,56 +242,6 @@ export const Multiple: Story = { docs: { description: { story: 'Activation toggles options and keeps the list open; the trigger summarizes long selections.' } }, }, wdio: { - 'DRP-007 reopens on the most recently toggled selected option rather than the Up edge': async (context) => { - const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { inWindowWith } = await import('../menu/menu.wdio.ts'); - const { browser, expect } = context; - await (await import('./dropdown.wdio.ts')).openDropdownFromKeyboard(context, 'Apple'); - await browser.keys('\uE007'); - await expect(await browser.$('~dropdown-selection')).toHaveText('Apple'); - await browser.keys('\uE015'); - await expect(await browser.$('~dropdown-focus')).toHaveText('Banana'); - await browser.keys('\uE007'); - await expect(await browser.$('~dropdown-selection')).toHaveText('Apple, Banana'); - await browser.keys('\uE00C'); - await expect(await browser.$('~dropdown-reason')).toHaveText('Closed: dismiss'); - await expectNativeState(browser, 'dropdown-trigger', 'focused', true); - - await browser.keys('\uE013'); - await expect(await browser.$('~dropdown-reason')).toHaveText('Opened: trigger'); - await expect(await browser.$('~dropdown-focus')).toHaveText('Banana'); - expect(await (await browser.$('~dropdown-trigger')).getProperty('value')).toBe('Apple, Banana'); - await inWindowWith(context, 'dropdown-banana', async () => { - await expectNativeState(browser, 'dropdown-banana', 'focused', true); - }); - }, - 'DRP-007 falls back to the first selected option after the latest option is deselected': async (context) => { - const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); - if (!requireDesktopFocus(context)) return; - const { inWindowWith } = await import('../menu/menu.wdio.ts'); - const { browser, expect } = context; - await (await import('./dropdown.wdio.ts')).openDropdownFromKeyboard(context, 'Apple'); - await browser.keys('\uE007'); - await expect(await browser.$('~dropdown-selection')).toHaveText('Apple'); - await browser.keys('\uE015'); - await expect(await browser.$('~dropdown-focus')).toHaveText('Banana'); - await browser.keys('\uE007'); - await expect(await browser.$('~dropdown-selection')).toHaveText('Apple, Banana'); - await browser.keys('\uE007'); - await expect(await browser.$('~dropdown-selection')).toHaveText('Apple'); - await browser.keys('\uE00C'); - await expect(await browser.$('~dropdown-reason')).toHaveText('Closed: dismiss'); - await expectNativeState(browser, 'dropdown-trigger', 'focused', true); - - await browser.keys('\uE013'); - await expect(await browser.$('~dropdown-reason')).toHaveText('Opened: trigger'); - await expect(await browser.$('~dropdown-focus')).toHaveText('Apple'); - expect(await (await browser.$('~dropdown-trigger')).getProperty('value')).toBe('Apple'); - await inWindowWith(context, 'dropdown-apple', async () => { - await expectNativeState(browser, 'dropdown-apple', 'focused', true); - }); - }, 'toggles options and stays open until dismissed': async (context) => { const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); if (!requireDesktopFocus(context)) return; From 66154c0df2a677a24c3f7e44f356cf8e51e0d4b3 Mon Sep 17 00:00:00 2001 From: Srividya Ramaswamy Date: Wed, 28 Oct 2026 15:32:37 -0700 Subject: [PATCH 3/3] test(dropdown): qualify empty native values by renderer Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b66f3ef-85ac-4405-bd35-dcc167a077b1 --- .../components/dropdown/dropdown.stories.tsx | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/packages/agentic/components/src/components/dropdown/dropdown.stories.tsx b/packages/agentic/components/src/components/dropdown/dropdown.stories.tsx index 8e016e4c54..af904d28d0 100644 --- a/packages/agentic/components/src/components/dropdown/dropdown.stories.tsx +++ b/packages/agentic/components/src/components/dropdown/dropdown.stories.tsx @@ -120,12 +120,17 @@ type Story = WdioStory>; export const Default: Story = { tags: ['desktop-focus'], wdio: { - 'DRP-011 exposes a named, empty, collapsed native combobox before input': async ({ browser, expect }) => { + 'DRP-011 exposes a named, empty, collapsed native combobox before input': async ({ browser, expect, platform }) => { const trigger = await browser.$('~dropdown-trigger'); expect(await trigger.getProperty('role')).toBe('combobox'); expect(await trigger.getProperty('name')).toBe('Fruit'); expect(await trigger.getProperty('expanded')).toBe(false); - expect(await trigger.getProperty('value')).toBe(''); + const renderer = 'furn:renderer' in browser.capabilities ? browser.capabilities['furn:renderer'] : undefined; + if (platform === 'macos') { + expect(['fabric', 'paper']).toContain(renderer); + } + // macOS Fabric converts empty accessibilityValue.text to nil; Paper and UIA retain the empty string. + expect(await trigger.getProperty('value')).toBe(platform === 'macos' && renderer === 'fabric' ? null : ''); }, 'opens from the keyboard on the first option and commits with Enter': async (context) => { const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); @@ -345,7 +350,12 @@ function GalleryScene() { )), )} - + {options} @@ -360,9 +370,23 @@ function GalleryScene() { export const Appearance: Story = { render: () => , + wdio: { + 'DRP-011 exposes a nonempty selected native value without input': async ({ browser, expect }) => { + const trigger = await browser.$('~dropdown-selected-trigger'); + expect(await trigger.getProperty('role')).toBe('combobox'); + expect(await trigger.getProperty('name')).toBe('Selected'); + expect(await trigger.getProperty('expanded')).toBe(false); + expect(await trigger.getProperty('value')).toBe('Banana'); + }, + }, parameters: { controls: { disable: true }, - docs: { description: { story: "Input's outline and underline chrome at each size, with selected, error, and disabled states." } }, + docs: { + description: { + story: + "Input's outline and underline chrome at each size, with selected, error, and disabled states. Native selected values remain the option label on every renderer, including macOS Fabric, which omits empty AXValue.", + }, + }, }, };