Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-zones-traverse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@fluentui-react-native/focus-zone': patch
---

Fix Windows Fabric FocusZone Shift+Tab traversal when the zone is a first child so backward navigation cannot re-enter the zone or jump to a later sibling.
18 changes: 18 additions & 0 deletions apps/E2E/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,24 @@ and drag & drop the **XCode Helper** app to **Security & Privacy -> Privacy -> A

# Running E2E Tests

## Windows and Win32 Appium drivers

After the repository's `yarn` install, run `yarn e2eprep:windows` or
`yarn e2eprep:win32` from `apps/E2E`. These commands verify that Appium discovers
and imports the Windows driver already installed from the Yarn lockfile.
They do not download another driver from npm.

Windows and Win32 test commands use `APPIUM_HOME=.` to discover the workspace's
Yarn-managed drivers and load only the Windows driver. Do not change this to
`.appium` or run an unversioned `appium driver install windows`: that creates a
separate dependency tree outside the lockfile and can leave the driver unable
to import its Appium peer. Existing `.appium` installations are ignored by these
two endpoints; the mobile and macOS commands retain their existing setup.

Run `yarn test:appium` to check driver discovery, the locked package path and
version, the actual driver import, and the Windows/Win32 runner configuration
without launching a native app.

## Win32 Steps

1. Install node packages, build JS
Expand Down
36 changes: 36 additions & 0 deletions apps/E2E/appium.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
const assert = require('node:assert/strict');
const { execFileSync } = require('node:child_process');
const { realpathSync } = require('node:fs');
const path = require('node:path');
const { test } = require('node:test');
const { pathToFileURL } = require('node:url');

const { scripts } = require('./package.json');

test('Appium discovers and loads the Yarn-managed Windows driver', async () => {
const output = execFileSync(process.execPath, [require.resolve('appium/index.js'), 'driver', 'list', '--installed', '--json'], {
cwd: __dirname,
encoding: 'utf8',
timeout: 60000,
});
const { windows } = JSON.parse(output);
assert.ok(windows?.installed, 'The Windows driver must be registered before E2E starts.');

const packagePath = require.resolve('appium-windows-driver/package.json');
const driverPackage = require(packagePath);
assert.equal(windows.version, driverPackage.version, 'Appium must use the driver version installed by Yarn.');
assert.equal(realpathSync(windows.installPath), realpathSync(path.dirname(packagePath)));

// Match Appium's dynamic import so missing peer dependencies fail during preparation.
const driver = await import(pathToFileURL(require.resolve(windows.installPath)).href);
assert.equal(typeof driver[windows.mainClass], 'function');
});

for (const platform of ['windows', 'win32']) {
test(`${platform} E2E uses the workspace Appium home and only the Windows driver`, () => {
assert.match(scripts[`e2etest:${platform}`], /\bAPPIUM_HOME=\.\s/);
const { config } = require(`./wdio.conf.${platform}.js`);
const appiumService = config.services.find(([name]) => name === 'appium');
assert.equal(appiumService[1].args['use-drivers'], 'windows');
});
}
11 changes: 6 additions & 5 deletions apps/E2E/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,15 @@
"e2eprep:android": "cross-env APPIUM_HOME=.appium yarn exec appium driver install uiautomator2",
"e2eprep:ios": "cross-env APPIUM_HOME=.appium yarn exec appium driver install xcuitest",
"e2eprep:macos": "cross-env APPIUM_HOME=.appium yarn exec appium driver install mac2",
"e2eprep:win32": "cross-env APPIUM_HOME=.appium yarn exec appium driver install windows",
"e2eprep:windows": "cross-env APPIUM_HOME=.appium yarn exec appium driver install windows",
"e2eprep:win32": "yarn test:appium",
"e2eprep:windows": "yarn test:appium",
"e2etest:android": "cross-env APPIUM_HOME=.appium wdio run wdio.conf.android.js",
"e2etest:ios": "cross-env APPIUM_HOME=.appium wdio run wdio.conf.ios.js",
"e2etest:macos": "cross-env APPIUM_HOME=.appium wdio run wdio.conf.macos.js",
"e2etest:win32": "cross-env APPIUM_HOME=.appium wdio run wdio.conf.win32.js",
"e2etest:windows": "rimraf errorShots reports && cross-env APPIUM_HOME=.appium wdio run wdio.conf.windows.js",
"lint": "fluentui-scripts lint"
"e2etest:win32": "cross-env APPIUM_HOME=. wdio run wdio.conf.win32.js",
"e2etest:windows": "rimraf errorShots reports && cross-env APPIUM_HOME=. wdio run wdio.conf.windows.js",
"lint": "fluentui-scripts lint",
"test:appium": "cross-env APPIUM_HOME=. node --test appium.test.cjs"
},
"dependencies": {
"@office-iss/rex-win32": "0.81.1"
Expand Down
1 change: 1 addition & 0 deletions apps/E2E/wdio.conf.win32.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ exports.config = {
'appium',
{
command: 'appium',
args: { 'use-drivers': 'windows' },
logPath: './reports/',
},
],
Expand Down
1 change: 1 addition & 0 deletions apps/E2E/wdio.conf.windows.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ exports.config = {
'appium',
{
command: 'appium',
args: { 'use-drivers': 'windows' },
logPath: './reports/',
},
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,40 @@ const FocusZoneExample = ({ columns = 3, defaultToCenter = false, itemCount = 9,
);
};

const FirstChildTabExitExample = (props: FocusZoneProps) => {
const [focusedItem, setFocusedItem] = React.useState('');
const renderButton = (id: string, content: string) => (
<FocusZoneStoryButton
content={content}
focused={focusedItem === id}
onFocus={() => setFocusedItem(id)}
style={styles.outsideButton}
testID={id}
/>
);

return (
<View style={styles.story} testID="focus-zone-first-child-story">
<View collapsable={false}>
{renderButton('focus-zone-earlier', 'Earlier outside')}
{renderButton('focus-zone-previous', 'Previous outside')}
</View>
<View collapsable={false}>
<View collapsable={false}>
<FocusZone {...props} style={styles.focusZone} testID="focus-zone-first-child-root">
<View style={styles.gridRow}>
{renderButton('focus-zone-first-child-first', 'First in zone')}
{renderButton('focus-zone-first-child-last', 'Last in zone')}
</View>
</FocusZone>
{renderButton('focus-zone-later-sibling', 'Later sibling')}
</View>
{renderButton('focus-zone-later-ancestor-sibling', 'Later ancestor sibling')}
</View>
</View>
);
};

const meta: Meta<typeof FocusZone> = {
title: 'Native/FocusZone',
component: FocusZone,
Expand Down Expand Up @@ -210,6 +244,19 @@ export const Default: Story = {
await browser.keys('\uE004');
await expectNativeState(browser, 'focus-zone-after', 'focused', true);
},
'Shift+Tab exits to a preceding sibling': async (context) => {
const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts');
if (!requireDesktopFocus(context)) return;
const { browser } = context;
await (await browser.$('~focus-zone-item-2')).click();
await expectNativeState(browser, 'focus-zone-item-2', 'focused', true);
try {
await browser.keys(['\uE008', '\uE004']);
} finally {
await browser.releaseActions();
}
await expectNativeState(browser, 'focus-zone-before', 'focused', true);
},
},
parameters: {
docs: {
Expand Down Expand Up @@ -291,6 +338,51 @@ export const TabNavigation: Story = {
},
};

export const FirstChildTabExit: Story = {
tags: ['desktop-e2e'],
args: {
tabKeyNavigation: 'None',
},
render: (args) => <FirstChildTabExitExample {...args} />,
wdio: {
'Shift+Tab skips the first-child zone and later siblings while climbing ancestors': async (context) => {
const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts');
if (!requireDesktopFocus(context)) return;
const { browser } = context;
for (const item of ['focus-zone-first-child-first', 'focus-zone-first-child-last']) {
await (await browser.$(`~${item}`)).click();
await expectNativeState(browser, item, 'focused', true);
try {
await browser.keys(['\uE008', '\uE004']);
} finally {
await browser.releaseActions();
}
await expectNativeState(browser, 'focus-zone-previous', 'focused', true);
await expectNativeState(browser, item, 'focused', false);
await expectNativeState(browser, 'focus-zone-later-sibling', 'focused', false);
await expectNativeState(browser, 'focus-zone-later-ancestor-sibling', 'focused', false);
}
},
'Tab exits a first-child zone to its next sibling': async (context) => {
const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts');
if (!requireDesktopFocus(context)) return;
const { browser } = context;
await (await browser.$('~focus-zone-first-child-first')).click();
await expectNativeState(browser, 'focus-zone-first-child-first', 'focused', true);
await browser.keys('\uE004');
await expectNativeState(browser, 'focus-zone-later-sibling', 'focused', true);
},
},
parameters: {
docs: {
description: {
story:
'The zone is the first native child of nested non-focusable containers with later siblings. Shift+Tab must climb past the containers to the last focusable element in the preceding subtree, never re-enter the zone or move forward. This shared desktop contract guards a Windows Fabric traversal regression.',
},
},
},
};

export const Disabled: Story = {
args: {
disabled: true,
Expand Down
11 changes: 10 additions & 1 deletion packages/components/FocusZone/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ because it owns native macOS code and CocoaPods integration.
forwarded without theme tokens or appearance defaults.
- `isCircularNavigation` maps to the native `NavigateWrap` end behavior; otherwise
navigation stops at the ends.
- With `tabKeyNavigation="None"`, Tab and Shift+Tab leave the zone. Backward
traversal considers preceding elements and focusable ancestors, not the zone's
descendants or later siblings, including when the zone is its parent's first
child.

## Platform behavior

Expand All @@ -27,7 +31,8 @@ because it owns native macOS code and CocoaPods integration.
moves to item 5.
- Windows includes a package-owned Fabric component view that coordinates
directional, Home/End, Tab, and focus-restoration behavior through RNW
`ComponentView` focus APIs.
`ComponentView` focus APIs. Backward zone exit walks reverse preorder and tests
each visited node itself rather than searching an ancestor's entire subtree.
- Win32 continues to use its platform-provided native FocusZone implementation.
Windows/Win32 use linear movement by default and opt into geometric movement
with `use2DNavigation`.
Expand All @@ -45,3 +50,7 @@ types.
Interactive directional, circular, Tab, disabled, and default-focus scenarios
live with the agentic primitive stories in
`packages/agentic/components/src/primitives/focus-zone/focus-zone.stories.tsx`.
The `FirstChildTabExit` native tests cover nested first-child containers with
later siblings and a preceding focusable subtree on all desktop endpoints.
They guard a Windows Fabric traversal defect; macOS uses its independent
AppKit key-view loop and shares the same outside-zone navigation requirement.
Original file line number Diff line number Diff line change
Expand Up @@ -452,8 +452,10 @@ struct FocusZoneComponentView
}
} else {
for (auto node = PreOrderPrevious(container); node; node = PreOrderPrevious(node)) {
auto candidate = winrtComp::FocusManager::FindLastFocusableElement(node);
if (candidate && candidate.TryFocus(winrtRN::FocusState::Keyboard)) {
// Reverse preorder already visits descendants. Only focus the node itself,
// since searching an ancestor's subtree can re-enter the zone or move forward.
auto candidate = winrtComp::FocusManager::FindFirstFocusableElement(node);
if (candidate && candidate.Tag() == node.Tag() && candidate.TryFocus(winrtRN::FocusState::Keyboard)) {
return true;
}
}
Expand Down
Loading