diff --git a/.changeset/calm-zones-focus.md b/.changeset/calm-zones-focus.md new file mode 100644 index 00000000000..a2961d3090f --- /dev/null +++ b/.changeset/calm-zones-focus.md @@ -0,0 +1,5 @@ +--- +'@fluentui-react-native/focus-zone': patch +--- + +Convert FocusZone to an unstyled phased primitive and add package-owned macOS and Windows Fabric implementations alongside Paper. diff --git a/.github/skills/agentic-storybook-development/SKILL.md b/.github/skills/agentic-storybook-development/SKILL.md index 04b2a86b154..bb2ac449acb 100644 --- a/.github/skills/agentic-storybook-development/SKILL.md +++ b/.github/skills/agentic-storybook-development/SKILL.md @@ -10,15 +10,20 @@ Work on the native Storybook application in `packages/agentic-components/storybo ## Workflow -1. Read the repository `AGENTS.md`, then the +1. Read the repository `AGENTS.md`, then the compact + [agent map](../../../packages/agentic-components/storybook/agent-map.yaml). Read the [Storybook instructions](../../../packages/agentic-components/storybook/AGENTS.md) and - [README](../../../packages/agentic-components/storybook/README.md). + [README](../../../packages/agentic-components/storybook/README.md) only as needed for the task. 2. Inspect the Storybook `package.json` and run its declared workspace scripts; do not invent direct runner commands. -3. Reproduce the first failure from the Storybook workspace so pnpm-linked React Native tooling resolves from the app. +3. Use `storybook-agent` to query runtime story IDs, select stories, and sweep renderability. Reproduce the first + failure from the Storybook workspace so pnpm-linked React Native tooling resolves from the app. 4. Treat native workspaces, Pods, lockfiles, generated solutions, build directories, and DerivedData as disposable outputs. Fix the owning manifest, Podfile, configuration, or script instead. 5. After dependency or workaround changes, regenerate the affected native dependency source and use a clean build so stale output cannot produce a false success. + When adding a codegen-enabled workspace dependency to existing macOS Pods, the first pod install can generate its + spec without refreshing ReactCodegen's source list. If the clean build then reports a missing generated spec header, + run `pods:macos` once more before changing native configuration. 6. Validate the JavaScript bundle and the affected native build. Run Storybook format/lint and the root build when manifests or shared package references changed. diff --git a/packages/agentic-components/storybook/AGENTS.md b/packages/agentic-components/storybook/AGENTS.md index f553e8d60f1..b6fc1c03ed9 100644 --- a/packages/agentic-components/storybook/AGENTS.md +++ b/packages/agentic-components/storybook/AGENTS.md @@ -2,7 +2,19 @@ These instructions apply to `packages/agentic-components/storybook` and its descendants. -Read this file, `README.md`, and `package.json` before changing the Storybook application or its native projects. +Read [`agent-map.yaml`](agent-map.yaml) first for the compact architecture, lookup, and interaction map. Read this file, +`README.md`, and `package.json` before changing the Storybook application or its native projects. + +## Agent-efficient discovery and interaction + +- Query the live story index with `yarn storybook-agent stories ` instead of guessing IDs or searching the + sidebar. +- Select stories with `yarn storybook-agent select ` and sweep renderability with + `yarn storybook-agent sweep [query]`. +- Use screenshots only for visual evidence. Use WebSocket events and accessibility bounds for lookup and interaction. +- Treat `src/storybook.requires.ts` as generated output; use `src/main.ts` for source configuration. +- Keep `agent-map.yaml` synchronized when stable paths, scripts, services, or interaction contracts change. Do not list + individual stories there; the runtime index is authoritative. ## Command and dependency discipline diff --git a/packages/agentic-components/storybook/README.md b/packages/agentic-components/storybook/README.md index 531be859242..da187a043df 100644 --- a/packages/agentic-components/storybook/README.md +++ b/packages/agentic-components/storybook/README.md @@ -3,7 +3,7 @@ On-device [Storybook](https://storybook.js.org/) app (Storybook for React Native v10) for `@fluentui-react-native/components` and linked standalone native packages. It loads every `*.stories.(ts|tsx)` file from the agentic library source (`../src`) plus the standalone -Callout package so its native stories run in the Fabric host. +Callout and FocusZone packages so their native stories run in the Fabric host. It runs in Storybook **liteMode**, which mocks out the heavy default on-device UI (`@storybook/react-native-ui`). This avoids the `react-native-reanimated` / @@ -16,6 +16,17 @@ unwrapped (`No theme`, the default) or apply the default light, dark, or high-co The selected Theme wraps the preview decorator, so it applies to every rendered story and remains selected while navigating between stories. +For agent-driven work, [`agent-map.yaml`](agent-map.yaml) is the compact map of stable files, +services, runtime events, lookup order, and native interaction rules. The checked-in +`yarn storybook-agent` helper queries the live index rather than duplicating story IDs: + +```sh +yarn storybook-agent status +yarn storybook-agent stories FocusZone +yarn storybook-agent select primitives-focuszone--circular-navigation +yarn storybook-agent sweep FocusZone +``` + ## Layout ``` @@ -92,6 +103,10 @@ Requires Visual Studio 2022 with the React Native Windows build prerequisites. T solution, `ExperimentalFeatures.props`, and build outputs are git-ignored and can be regenerated with `yarn windows:generate`. +FocusZone is autolinked as a package-owned Windows Fabric native library. Its +Windows component view handles directional navigation, single-stop Tab +navigation, and focus restoration while the existing Win32 implementation +remains platform-provided. The raw React Native Windows CLI path remains available as `yarn windows:cli`, but the declared `windows` workflow avoids two failure modes in this app: CLI deployment can stall while enabling Developer Mode, and starting Metro before the native build can make its watcher observe generated diff --git a/packages/agentic-components/storybook/agent-map.yaml b/packages/agentic-components/storybook/agent-map.yaml new file mode 100644 index 00000000000..763a1a05dd2 --- /dev/null +++ b/packages/agentic-components/storybook/agent-map.yaml @@ -0,0 +1,87 @@ +version: 1 +purpose: Agent-optimized map of stable Storybook structure and runtime discovery paths. + +sources: + config: src/main.ts + app_entry: index.js + app_shell: src/StorybookApp.tsx + theme_host: src/StorybookTheme.tsx + channel_server: storybook-server.cjs + generated_requires: + path: src/storybook.requires.ts + policy: generated-do-not-edit + story_globs: + - ../../src/**/*.stories.?(ts|tsx) + - ../../../components/Callout/src/**/*.stories.?(ts|tsx) + - ../../../components/FocusZone/src/**/*.stories.?(ts|tsx) + +services: + metro: + script: start + health_url: http://127.0.0.1:8081/status + channel: + script: storybook-server + websocket_url: ws://127.0.0.1:7007/ + mcp_url: http://127.0.0.1:7007/mcp + +runtime_protocol: + index: + request: + type: RN_GET_INDEX + args: [] + response: RN_GET_INDEX_RESPONSE + select: + request: + type: setCurrentStory + args: + - storyId: + success: storyFinished + failures: + - storyErrored + - storyMissing + - storyThrewException + +agent_commands: + status: yarn storybook-agent status + find_stories: yarn storybook-agent stories + select_story: yarn storybook-agent select + sweep_stories: yarn storybook-agent sweep [query] + +lookup_order: + - Use "storybook-agent stories " for runtime IDs; never guess ID slugging. + - Read this map for stable paths, services, and commands. + - Read src/main.ts for story-source changes. + - Read a matched story file for component-specific behavior. + - Read generated requires only to diagnose generation output. + +interaction_order: + - Use the WebSocket helper for listing, selecting, and sweeping stories. + - Use accessibility elements for native focus and control state. + - Use HID mouse or keyboard events when accessibility actions do not invoke React Native handlers. + - Use screenshots for visual evidence, not element lookup. + +native: + macos: + process: ReactTestApp + app_bundle: macos/DerivedData/Build/Products/Debug/ReactTestApp.app + coordinate_notes: + accessibility: logical-points + screenshots: backing-pixels + rule: Query element bounds and display scale; do not copy screenshot coordinates. + windows: + project_policy: generated + generation_script: windows:generate + +validation: + javascript: + - format + - lint + - bundle:macos + native: + normal: macos:build + after_native_or_pod_change: macos:build:clean + +output_hygiene: + - Use the helper's filtered and summary output instead of logging raw channel events. + - Search large native build logs for errors and final status instead of reading them in full. + - Keep warning suppression exact; console diagnostics remain available even when LogBox hides a warning. diff --git a/packages/agentic-components/storybook/index.js b/packages/agentic-components/storybook/index.js index 3003e18cb20..4c0970bebb2 100644 --- a/packages/agentic-components/storybook/index.js +++ b/packages/agentic-components/storybook/index.js @@ -1,5 +1,12 @@ -import { AppRegistry } from 'react-native'; +import { AppRegistry, LogBox } from 'react-native'; import StorybookApp from './src/StorybookApp'; import { name as appName } from './app.json'; +LogBox.ignoreLogs([ + // The supported React Native peer floor predates the public root codegen exports. + /^Deep imports from the 'react-native' package are deprecated \('react-native\/Libraries\/Utilities\/codegenNative(?:Commands|Component)'\)\./, + // Storybook's sidebar expands its LegendList pool on demand without affecting rendering. + '[legend-list] No unused container available, so creating one on demand.', +]); + AppRegistry.registerComponent(appName, () => StorybookApp); diff --git a/packages/agentic-components/storybook/package.json b/packages/agentic-components/storybook/package.json index 7994f70422a..7f21a331c2f 100644 --- a/packages/agentic-components/storybook/package.json +++ b/packages/agentic-components/storybook/package.json @@ -17,7 +17,8 @@ "macos:build": "xcodebuild -workspace macos/AgenticStorybook.xcworkspace -scheme AgenticStorybook -configuration Debug -destination 'platform=macOS' -derivedDataPath macos/DerivedData CODE_SIGNING_ALLOWED=NO build", "macos:build:clean": "xcodebuild -workspace macos/AgenticStorybook.xcworkspace -scheme AgenticStorybook -configuration Debug -destination 'platform=macOS' -derivedDataPath macos/DerivedData CODE_SIGNING_ALLOWED=NO clean build", "macos": "rnx-cli run --platform macos", - "windows": "pwsh -NoProfile -File scripts/start-windows-agent-session.ps1", + "storybook-agent": "node scripts/storybook-agent.cjs", + "windows": "react-native run-windows --arch x64 --sln windows/AgenticStorybook.sln", "windows:cli": "react-native run-windows --arch x64 --sln windows/AgenticStorybook.sln", "windows:info": "react-native run-windows --info --no-telemetry", "windows:build": "react-native run-windows --arch x64 --sln windows/AgenticStorybook.sln --no-packager --no-deploy --no-launch --logging --no-telemetry --buildLogDirectory artifacts/windows/build-logs", diff --git a/packages/agentic-components/storybook/scripts/smoke-stories.json b/packages/agentic-components/storybook/scripts/smoke-stories.json index 1277ba76000..708d4d4ab93 100644 --- a/packages/agentic-components/storybook/scripts/smoke-stories.json +++ b/packages/agentic-components/storybook/scripts/smoke-stories.json @@ -14,5 +14,10 @@ "testId": "agentic-storybook-callout-trigger", "statusTestId": "agentic-storybook-callout-status", "artifactName": "callout-default" + }, + { + "storyId": "primitives-focuszone--two-dimensional-navigation", + "testId": "focus-zone-item-1", + "artifactName": "focus-zone-two-dimensional-navigation" } ] diff --git a/packages/agentic-components/storybook/scripts/storybook-agent.cjs b/packages/agentic-components/storybook/scripts/storybook-agent.cjs new file mode 100644 index 00000000000..f20001bb9c7 --- /dev/null +++ b/packages/agentic-components/storybook/scripts/storybook-agent.cjs @@ -0,0 +1,211 @@ +#!/usr/bin/env node +'use strict'; + +/* eslint-disable @microsoft/sdl/no-insecure-url -- These endpoints are local development services. */ +const DEFAULT_WS_URL = 'ws://127.0.0.1:7007/'; +const DEFAULT_METRO_STATUS_URL = 'http://127.0.0.1:8081/status'; +const INDEX_REQUEST = 'RN_GET_INDEX'; +const INDEX_RESPONSE = 'RN_GET_INDEX_RESPONSE'; +const source = `storybook-agent-${process.pid}`; + +const [command = 'help', ...args] = process.argv.slice(2); +const wsUrl = process.env.STORYBOOK_WS_URL || DEFAULT_WS_URL; +const metroStatusUrl = process.env.STORYBOOK_METRO_STATUS_URL || DEFAULT_METRO_STATUS_URL; + +function emit(ws, type, ...eventArgs) { + ws.send(JSON.stringify({ type, args: eventArgs, from: source })); +} + +function parseMessage(event) { + try { + return JSON.parse(String(event.data)); + } catch { + return null; + } +} + +function openChannel() { + return new Promise((resolve, reject) => { + const ws = new WebSocket(wsUrl); + const timeout = setTimeout(() => { + ws.close(); + reject(new Error(`Timed out connecting to ${wsUrl}`)); + }, 5000); + + ws.addEventListener('open', () => { + clearTimeout(timeout); + resolve(ws); + }); + ws.addEventListener('error', () => { + clearTimeout(timeout); + reject(new Error(`Unable to connect to ${wsUrl}; run yarn storybook-server`)); + }); + }); +} + +function requestIndex(ws) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + ws.removeEventListener('message', onMessage); + reject(new Error('Timed out waiting for the Storybook runtime index')); + }, 5000); + + function onMessage(event) { + const message = parseMessage(event); + if (message?.type !== INDEX_RESPONSE) { + return; + } + + clearTimeout(timeout); + ws.removeEventListener('message', onMessage); + resolve(message.args[0].index.entries); + } + + ws.addEventListener('message', onMessage); + emit(ws, INDEX_REQUEST); + }); +} + +function storyIdFromMessage(message) { + const payload = message?.args?.[0]; + return typeof payload === 'string' ? payload : payload?.storyId; +} + +function selectStory(ws, storyId) { + return new Promise((resolve) => { + let settleTimer; + let sawRender = false; + + const rerenderTimer = setTimeout(() => emit(ws, 'forceReRender'), 500); + const timeout = setTimeout(() => finish({ storyId, status: 'timeout' }), 10000); + + function finish(result) { + clearTimeout(rerenderTimer); + clearTimeout(timeout); + clearTimeout(settleTimer); + ws.removeEventListener('message', onMessage); + resolve(result); + } + + function onMessage(event) { + const message = parseMessage(event); + if (!message) { + return; + } + + const eventStoryId = storyIdFromMessage(message); + if (eventStoryId !== storyId) { + return; + } + + if (message.type === 'storyChanged' || message.type === 'storyRenderPhaseChanged') { + sawRender = true; + clearTimeout(rerenderTimer); + clearTimeout(settleTimer); + } else if (message.type === 'currentStoryWasSet') { + clearTimeout(rerenderTimer); + settleTimer = setTimeout(() => { + if (!sawRender) { + finish({ storyId, status: 'unchanged' }); + } + }, 500); + } else if (message.type === 'storyFinished') { + finish(message.args[0]); + } else if (['storyErrored', 'storyMissing', 'storyThrewException'].includes(message.type)) { + finish({ storyId, status: 'error', event: message.type, detail: message.args[1] }); + } + } + + ws.addEventListener('message', onMessage); + emit(ws, 'setCurrentStory', { storyId }); + }); +} + +function matchingStories(entries, query = '') { + const normalizedQuery = query.toLowerCase(); + return Object.entries(entries) + .filter(([, entry]) => entry.type === 'story') + .filter(([id, entry]) => { + const searchable = `${id} ${entry.title || ''} ${entry.name || ''}`.toLowerCase(); + return searchable.includes(normalizedQuery); + }) + .sort(([left], [right]) => left.localeCompare(right)); +} + +async function run() { + if (command === 'help' || command === '--help' || command === '-h') { + console.log(`Usage: + yarn storybook-agent status + yarn storybook-agent stories [query] + yarn storybook-agent select + yarn storybook-agent sweep [query]`); + return; + } + + const ws = await openChannel(); + try { + if (command === 'status') { + const [metroResponse, entries] = await Promise.all([fetch(metroStatusUrl), requestIndex(ws)]); + const stories = matchingStories(entries); + console.log(`metro=${await metroResponse.text()} channel=connected stories=${stories.length}`); + return; + } + + const entries = await requestIndex(ws); + + if (command === 'stories') { + const stories = matchingStories(entries, args.join(' ')); + for (const [id, entry] of stories) { + console.log(`${id}\t${entry.title} / ${entry.name}`); + } + console.log(`stories=${stories.length}`); + return; + } + + if (command === 'select') { + const storyId = args[0]; + if (!storyId) { + throw new Error('select requires a runtime story ID'); + } + if (!entries[storyId] || entries[storyId].type !== 'story') { + throw new Error(`Unknown story ID: ${storyId}`); + } + + const result = await selectStory(ws, storyId); + console.log(`${result.storyId}\t${result.status}`); + if (!['success', 'unchanged'].includes(result.status)) { + process.exitCode = 1; + } + return; + } + + if (command === 'sweep') { + const stories = matchingStories(entries, args.join(' ')); + const failures = []; + for (const [storyId] of stories) { + const result = await selectStory(ws, storyId); + if (!['success', 'unchanged'].includes(result.status)) { + failures.push(result); + } + } + + console.log(`stories=${stories.length} failures=${failures.length}`); + for (const failure of failures) { + console.error(`${failure.storyId}\t${failure.status}\t${failure.event || ''}`); + } + if (failures.length > 0) { + process.exitCode = 1; + } + return; + } + + throw new Error(`Unknown command: ${command}`); + } finally { + ws.close(); + } +} + +run().catch((error) => { + console.error(error.message); + process.exitCode = 1; +}); diff --git a/packages/agentic-components/storybook/src/main.ts b/packages/agentic-components/storybook/src/main.ts index 03b90c569ad..f7b4935e0ce 100644 --- a/packages/agentic-components/storybook/src/main.ts +++ b/packages/agentic-components/storybook/src/main.ts @@ -7,7 +7,11 @@ import type { StorybookConfig } from '@storybook/react-native'; * that are linked into this application. */ const main: StorybookConfig = { - stories: ['../../src/**/*.stories.?(ts|tsx)', '../../../components/Callout/src/**/*.stories.?(ts|tsx)'], + stories: [ + '../../src/**/*.stories.?(ts|tsx)', + '../../../components/Callout/src/**/*.stories.?(ts|tsx)', + '../../../components/FocusZone/src/**/*.stories.?(ts|tsx)', + ], addons: [], deviceAddons: ['@storybook/addon-ondevice-controls', '@storybook/addon-ondevice-actions'], }; diff --git a/packages/agentic-components/storybook/windows-tests/storybook-smoke.test.cjs b/packages/agentic-components/storybook/windows-tests/storybook-smoke.test.cjs index fd86a3b737d..e2d551b2ad9 100644 --- a/packages/agentic-components/storybook/windows-tests/storybook-smoke.test.cjs +++ b/packages/agentic-components/storybook/windows-tests/storybook-smoke.test.cjs @@ -6,6 +6,9 @@ const smokeStories = require('../scripts/smoke-stories.json'); const { getIndex, selectStory } = require('../scripts/storybook-client.cjs'); const artifactsDirectory = path.join(__dirname, '..', 'artifacts', 'windows', 'automation'); +const arrowRight = '\uE014'; +const arrowDown = '\uE015'; +const tab = '\uE004'; beforeAll(async () => { fs.mkdirSync(artifactsDirectory, { recursive: true }); @@ -33,3 +36,32 @@ test.each(smokeStories)('renders $storyId with stable native selectors', async ( `${JSON.stringify({ storyId, testId, displayed, statusTestId, statusText }, null, 2)}\n`, ); }); + +test('FocusZone performs geometric two-dimensional navigation', async () => { + await selectStory('primitives-focuszone--two-dimensional-navigation'); + + const firstItem = await app.findElementByTestID('focus-zone-item-1'); + const fourthItem = await app.findElementByTestID('focus-zone-item-4'); + await firstItem.click(); + expect(await firstItem.getAttribute('HasKeyboardFocus')).toBe('True'); + + await firstItem.sendKeys([arrowDown]); + expect(await fourthItem.getAttribute('HasKeyboardFocus')).toBe('True'); +}); + +test('FocusZone performs linear navigation and exits on Tab', async () => { + await selectStory('primitives-focuszone--default'); + + const firstItem = await app.findElementByTestID('focus-zone-item-1'); + const secondItem = await app.findElementByTestID('focus-zone-item-2'); + const thirdItem = await app.findElementByTestID('focus-zone-item-3'); + const afterZone = await app.findElementByTestID('focus-zone-after'); + await firstItem.click(); + + await firstItem.sendKeys([arrowRight]); + expect(await secondItem.getAttribute('HasKeyboardFocus')).toBe('True'); + await secondItem.sendKeys([arrowDown]); + expect(await thirdItem.getAttribute('HasKeyboardFocus')).toBe('True'); + await thirdItem.sendKeys([tab]); + expect(await afterZone.getAttribute('HasKeyboardFocus')).toBe('True'); +}); diff --git a/packages/components/FocusZone/RCTFocusZone.podspec b/packages/components/FocusZone/RCTFocusZone.podspec index e94f1268633..13ae57174eb 100644 --- a/packages/components/FocusZone/RCTFocusZone.podspec +++ b/packages/components/FocusZone/RCTFocusZone.podspec @@ -17,5 +17,5 @@ Pod::Spec.new do |s| s.osx.deployment_target = "10.15" s.osx.source_files = "macos/*.{swift,h,m,mm}" - s.dependency 'React' + install_modules_dependencies(s) end diff --git a/packages/components/FocusZone/SPEC.md b/packages/components/FocusZone/SPEC.md new file mode 100644 index 00000000000..669601503f6 --- /dev/null +++ b/packages/components/FocusZone/SPEC.md @@ -0,0 +1,41 @@ +# FocusZone + +## Purpose + +FocusZone is an unstyled native primitive that coordinates directional and Tab +keyboard navigation among focusable descendants. It remains a standalone package +because it owns native macOS code and CocoaPods integration. + +## Contract + +- `FocusZoneProps` extends React Native `ViewProps` with directional, circular, + Tab, disabled, and default-tabbable-element behavior. +- `defaultTabbableElement` accepts a React component ref or registered native + identifier. +- `componentRef` exposes the native view, including its `focus()` method. +- Children, accessibility props, test props, native events, and caller styles are + forwarded without theme tokens or appearance defaults. +- `isCircularNavigation` maps to the native `NavigateWrap` end behavior; otherwise + navigation stops at the ends. + +## Platform behavior + +- macOS includes both the existing Paper view manager and a package-owned Fabric + component view. Both reuse the same `RCTFocusZone` navigation implementation. +- Windows includes a package-owned Fabric component view that coordinates + directional, Home/End, Tab, and focus-restoration behavior through RNW + `ComponentView` focus APIs. +- Win32 continues to use its platform-provided native FocusZone implementation. +- Unsupported behavior remains platform-defined rather than being simulated in + JavaScript. + +## Compatibility + +`FocusZoneState`, `FocusZoneTokens`, `FocusZoneSlotProps`, +`FocusZoneRenderData`, and `FocusZoneType` remain as deprecated compatibility +types. + +## Demonstration + +Interactive directional, circular, Tab, disabled, and default-focus scenarios +are colocated in `src/FocusZone.stories.tsx`. diff --git a/packages/components/FocusZone/macos/RCTFocusZoneComponentView.h b/packages/components/FocusZone/macos/RCTFocusZoneComponentView.h new file mode 100644 index 00000000000..2070528cd01 --- /dev/null +++ b/packages/components/FocusZone/macos/RCTFocusZoneComponentView.h @@ -0,0 +1,14 @@ +#pragma once + +#ifdef RCT_NEW_ARCH_ENABLED + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface RCTFocusZoneComponentView : RCTViewComponentView +@end + +NS_ASSUME_NONNULL_END + +#endif diff --git a/packages/components/FocusZone/macos/RCTFocusZoneComponentView.mm b/packages/components/FocusZone/macos/RCTFocusZoneComponentView.mm new file mode 100644 index 00000000000..1859ff1dc38 --- /dev/null +++ b/packages/components/FocusZone/macos/RCTFocusZoneComponentView.mm @@ -0,0 +1,186 @@ +#ifdef RCT_NEW_ARCH_ENABLED + +#import "RCTFocusZoneComponentView.h" + +#import +#import +#import +#import + +#import + +#import "RCTFocusZone.h" + +using namespace facebook::react; + +namespace facebook::react { + +extern const char FocusZoneComponentName[] = "FocusZone"; + +using FocusZoneShadowNode = ConcreteViewShadowNode< + FocusZoneComponentName, + RCTFocusZoneProps, + RCTFocusZoneEventEmitter, + RCTFocusZoneState>; +using FocusZoneComponentDescriptor = ConcreteComponentDescriptor; + +} // namespace facebook::react + +static FocusZoneDirection RCTFocusZoneDirectionFromProp(RCTFocusZoneFocusZoneDirection direction) +{ + switch (direction) { + case RCTFocusZoneFocusZoneDirection::Bidirectional: + return FocusZoneDirectionBidirectional; + case RCTFocusZoneFocusZoneDirection::Horizontal: + return FocusZoneDirectionHorizontal; + case RCTFocusZoneFocusZoneDirection::Vertical: + return FocusZoneDirectionVertical; + case RCTFocusZoneFocusZoneDirection::None: + return FocusZoneDirectionNone; + } +} + +static NSString *RCTNavigateAtEndFromProp(RCTFocusZoneNavigateAtEnd navigateAtEnd) +{ + switch (navigateAtEnd) { + case RCTFocusZoneNavigateAtEnd::NavigateStopAtEnds: + return @"NavigateStopAtEnds"; + case RCTFocusZoneNavigateAtEnd::NavigateWrap: + return @"NavigateWrap"; + case RCTFocusZoneNavigateAtEnd::NavigateContinue: + return @"NavigateContinue"; + } +} + +static NSString *RCTTabKeyNavigationFromProp(RCTFocusZoneTabKeyNavigation tabKeyNavigation) +{ + switch (tabKeyNavigation) { + case RCTFocusZoneTabKeyNavigation::None: + return @"None"; + case RCTFocusZoneTabKeyNavigation::NavigateWrap: + return @"NavigateWrap"; + case RCTFocusZoneTabKeyNavigation::NavigateStopAtEnds: + return @"NavigateStopAtEnds"; + case RCTFocusZoneTabKeyNavigation::Normal: + return @"Normal"; + } +} + +static RCTPlatformView *RCTFindComponentViewWithTag(RCTPlatformView *rootView, NSInteger tag) +{ + if ([rootView conformsToProtocol:@protocol(RCTComponentViewProtocol)] && + [((id)rootView).reactTag integerValue] == tag) { + return rootView; + } + + for (RCTPlatformView *subview in rootView.subviews) { + RCTPlatformView *match = RCTFindComponentViewWithTag(subview, tag); + if (match) { + return match; + } + } + + return nil; +} + +@interface RCTFocusZoneComponentView () +@end + +@implementation RCTFocusZoneComponentView { + NSInteger _defaultResponderTag; + RCTFocusZone *_focusZone; +} + ++ (ComponentDescriptorProvider)componentDescriptorProvider +{ + // Fabric normalizes the legacy RCT-prefixed view name before component lookup. + return concreteComponentDescriptorProvider(); +} + +- (instancetype)initWithFrame:(CGRect)frame +{ + if (self = [super initWithFrame:frame]) { + static const auto defaultProps = std::make_shared(); + _props = defaultProps; + + _focusZone = [[RCTFocusZone alloc] initWithFrame:self.bounds]; + self.contentView = _focusZone; + } + return self; +} + +- (NSView *)accessibilityElement +{ + return _focusZone; +} + +- (void)mountChildComponentView:(RCTUIView *)childComponentView index:(NSInteger)index +{ + [_focusZone insertSubview:childComponentView atIndex:index]; + [self updateDefaultResponder]; +} + +- (void)unmountChildComponentView:(RCTUIView *)childComponentView index:(__unused NSInteger)index +{ + [childComponentView removeFromSuperview]; + [self updateDefaultResponder]; +} + +- (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &)oldProps +{ + const auto &newProps = *std::static_pointer_cast(props); + + _focusZone.disabled = newProps.disabled; + _focusZone.focusZoneDirection = RCTFocusZoneDirectionFromProp(newProps.focusZoneDirection); + _focusZone.navigateAtEnd = RCTNavigateAtEndFromProp(newProps.navigateAtEnd); + _focusZone.navigationOrderInRenderOrder = newProps.navigationOrderInRenderOrder; + _focusZone.tabKeyNavigation = RCTTabKeyNavigationFromProp(newProps.tabKeyNavigation); + + _defaultResponderTag = 0; + if (newProps.defaultTabbableElement.isNumber()) { + _defaultResponderTag = (NSInteger)newProps.defaultTabbableElement.asDouble(); + } + [self updateDefaultResponder]; + [self.window recalculateKeyViewLoop]; + + [super updateProps:props oldProps:oldProps]; +} + +- (void)viewDidMoveToWindow +{ + [super viewDidMoveToWindow]; + [self updateDefaultResponder]; +} + +- (void)prepareForRecycle +{ + [super prepareForRecycle]; + _defaultResponderTag = 0; + _focusZone.defaultResponder = nil; +} + +- (BOOL)acceptsFirstResponder +{ + return _focusZone.acceptsFirstResponder; +} + +- (BOOL)becomeFirstResponder +{ + return [_focusZone becomeFirstResponder]; +} + +- (void)keyDown:(NSEvent *)event +{ + [_focusZone keyDown:event]; +} + +- (void)updateDefaultResponder +{ + RCTPlatformView *rootView = self.window.contentView; + _focusZone.defaultResponder = + _defaultResponderTag > 0 && rootView ? RCTFindComponentViewWithTag(rootView, _defaultResponderTag) : nil; +} + +@end + +#endif diff --git a/packages/components/FocusZone/package.json b/packages/components/FocusZone/package.json index e1ac70d5c96..3ced39cdb25 100644 --- a/packages/components/FocusZone/package.json +++ b/packages/components/FocusZone/package.json @@ -30,10 +30,7 @@ "update-snapshots": "fluentui-scripts jest -u" }, "dependencies": { - "@fluentui-react-native/adapters": "workspace:*", - "@fluentui-react-native/interactive-hooks": "workspace:*", - "@uifabricshared/foundation-composable": "workspace:*", - "@uifabricshared/foundation-settings": "workspace:*" + "@fluentui-react-native/framework-base": "workspace:*" }, "devDependencies": { "@babel/core": "catalog:", @@ -43,15 +40,16 @@ "@react-native-community/cli-platform-android": "^20.0.0", "@react-native-community/cli-platform-ios": "^20.0.0", "@react-native/babel-preset": "^0.81.0", - "@react-native/metro-babel-transformer": "^0.81.0", "@react-native/metro-config": "^0.81.0", + "@testing-library/react-native": "catalog:0.81", "@types/react": "~19.1.4", "@types/react-test-renderer": "^19.1.0", "react": "19.1.4", "react-native": "^0.81.6", "react-native-macos": "^0.81.0", "react-native-windows": "^0.81.0", - "react-test-renderer": "19.1.4" + "react-test-renderer": "19.1.4", + "test-renderer": "catalog:0.81" }, "peerDependencies": { "@office-iss/react-native-win32": "^0.74.0 || ^0.81.0", @@ -75,9 +73,33 @@ "optional": true } }, + "codegenConfig": { + "name": "FRNFocusZoneSpec", + "type": "all", + "jsSrcsDir": "src", + "includesGeneratedCode": true, + "ios": { + "componentProvider": { + "FocusZone": "RCTFocusZoneComponentView" + } + }, + "windows": { + "generators": "componentsWindows", + "namespace": "FRNFocusZoneCodegen", + "outputDirectory": "windows/FRNFocusZone/codegen", + "separateDataTypes": true + } + }, "furn": { "jestPlatform": "win32" }, + "react-native-windows": { + "init-windows": { + "name": "FRNFocusZone", + "namespace": "FRNFocusZone", + "template": "cpp-lib" + } + }, "rnx-kit": { "kitType": "library", "alignDeps": { @@ -88,7 +110,6 @@ "core-ios", "core-macos", "core-windows", - "metro-react-native-babel-transformer", "react", "react-test-renderer" ] diff --git a/packages/components/FocusZone/react-native.config.cjs b/packages/components/FocusZone/react-native.config.cjs new file mode 100644 index 00000000000..e6858db4296 --- /dev/null +++ b/packages/components/FocusZone/react-native.config.cjs @@ -0,0 +1,15 @@ +module.exports = { + dependency: { + platforms: { + windows: { + sourceDir: 'windows', + projects: [ + { + projectFile: 'FRNFocusZone/FRNFocusZone.vcxproj', + directDependency: true, + }, + ], + }, + }, + }, +}; diff --git a/packages/components/FocusZone/src/FocusZone.stories.tsx b/packages/components/FocusZone/src/FocusZone.stories.tsx new file mode 100644 index 00000000000..f51de7741cf --- /dev/null +++ b/packages/components/FocusZone/src/FocusZone.stories.tsx @@ -0,0 +1,276 @@ +/** @jsxImportSource @fluentui-react-native/framework-base */ +import * as React from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import type { Meta, StoryObj } from '@storybook/react-native'; + +import { FocusZone } from './FocusZone'; +import type { FocusZoneDirection, FocusZoneProps, FocusZoneTabNavigation } from './FocusZone.types'; + +const directions: readonly FocusZoneDirection[] = ['bidirectional', 'horizontal', 'vertical', 'none']; +const tabNavigationModes: readonly FocusZoneTabNavigation[] = ['None', 'NavigateWrap', 'NavigateStopAtEnds', 'Normal']; + +type FocusZoneExampleProps = FocusZoneProps & { + defaultToCenter?: boolean; + columns?: number; + itemCount?: number; +}; + +const FocusZoneExample = ({ columns = 3, defaultToCenter = false, itemCount = 9, ...props }: FocusZoneExampleProps) => { + const beforeRef = React.useRef(null); + const afterRef = React.useRef(null); + const focusZoneRef = React.useRef(null); + const centerRef = React.useRef(null); + const itemRefs = React.useRef<(View | null)[]>([]); + const [focusedItem, setFocusedItem] = React.useState('Outside before'); + const items = Array.from({ length: itemCount }, (_, index) => index + 1); + const centerIndex = Math.floor(itemCount / 2); + + return ( + + setFocusedItem('Outside before')} + onPress={() => beforeRef.current?.focus()} + ref={beforeRef} + style={({ pressed }) => [styles.outsideButton, pressed && styles.pressed]} + testID="focus-zone-before" + > + Outside before + + + + {items.map((item, index) => ( + setFocusedItem(`Item ${item}`)} + onPress={() => itemRefs.current[index]?.focus()} + ref={(view) => { + itemRefs.current[index] = view; + if (index === centerIndex) { + centerRef.current = view; + } + }} + style={({ pressed }) => [ + styles.item, + { flexBasis: `${100 / columns - 3}%` }, + focusedItem === `Item ${item}` && styles.focused, + pressed && styles.pressed, + ]} + testID={`focus-zone-item-${item}`} + > + {item} + + ))} + + + setFocusedItem('Outside after')} + onPress={() => afterRef.current?.focus()} + ref={afterRef} + style={({ pressed }) => [styles.outsideButton, pressed && styles.pressed]} + testID="focus-zone-after" + > + Outside after + + focusZoneRef.current?.focus()} + style={({ pressed }) => [styles.focusButton, pressed && styles.pressed]} + > + Focus the zone + + + Focused: {focusedItem} + + + ); +}; + +const meta: Meta = { + title: 'Primitives/FocusZone', + component: FocusZone, + args: { + disabled: false, + focusZoneDirection: 'bidirectional', + isCircularNavigation: false, + navigationOrderInRenderOrder: false, + tabKeyNavigation: 'None', + use2DNavigation: false, + }, + argTypes: { + disabled: { control: 'boolean' }, + focusZoneDirection: { control: 'select', options: directions }, + isCircularNavigation: { control: 'boolean' }, + navigationOrderInRenderOrder: { control: 'boolean' }, + tabKeyNavigation: { control: 'select', options: tabNavigationModes }, + use2DNavigation: { control: 'boolean' }, + }, + parameters: { + docs: { + description: { + component: + 'FocusZone is an unstyled native primitive that coordinates directional and Tab keyboard navigation among focusable descendants.', + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => , + parameters: { + docs: { + description: { + story: 'Focus an item, then use the arrow keys to move through the two-dimensional grid.', + }, + }, + }, +}; + +export const CircularNavigation: Story = { + args: { + focusZoneDirection: 'horizontal', + isCircularNavigation: true, + }, + render: (args) => , + parameters: { + docs: { + description: { + story: 'Horizontal arrow navigation wraps from the first and last items.', + }, + }, + }, +}; + +export const TwoDimensionalNavigation: Story = { + args: { + use2DNavigation: true, + }, + render: (args) => , + parameters: { + docs: { + description: { + story: 'Geometric navigation moves vertically between rows and horizontally within each row.', + }, + }, + }, +}; + +export const DefaultTabbableElement: Story = { + render: (args) => , + parameters: { + docs: { + description: { + story: 'The Focus the zone action sends focus to the center item through defaultTabbableElement.', + }, + }, + }, +}; + +export const TabNavigation: Story = { + args: { + focusZoneDirection: 'horizontal', + tabKeyNavigation: 'NavigateWrap', + }, + render: (args) => , + parameters: { + docs: { + description: { + story: 'Tab and Shift+Tab move within the zone and wrap at its ends.', + }, + }, + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + }, + render: (args) => , + parameters: { + docs: { + description: { + story: 'A disabled FocusZone leaves keyboard navigation to the platform responder chain.', + }, + }, + }, +}; + +const styles = StyleSheet.create({ + focusButton: { + backgroundColor: '#0f6cbd', + borderRadius: 4, + marginTop: 16, + paddingHorizontal: 16, + paddingVertical: 10, + }, + focusButtonText: { + color: '#ffffff', + fontWeight: '600', + }, + focused: { + borderColor: '#0f6cbd', + borderWidth: 3, + }, + focusZone: { + marginVertical: 12, + width: 360, + }, + grid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + item: { + alignItems: 'center', + backgroundColor: '#f5f5f5', + borderColor: '#d1d1d1', + borderRadius: 4, + borderWidth: 1, + justifyContent: 'center', + minHeight: 64, + }, + itemText: { + color: '#242424', + fontSize: 16, + fontWeight: '600', + }, + outsideButton: { + backgroundColor: '#ffffff', + borderColor: '#d1d1d1', + borderRadius: 4, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 8, + }, + pressed: { + opacity: 0.7, + }, + status: { + color: '#616161', + marginTop: 12, + }, + story: { + alignItems: 'center', + justifyContent: 'center', + minHeight: 420, + padding: 24, + width: 520, + }, +}); diff --git a/packages/components/FocusZone/src/FocusZone.ts b/packages/components/FocusZone/src/FocusZone.ts deleted file mode 100644 index 24d4d3886c9..00000000000 --- a/packages/components/FocusZone/src/FocusZone.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Copyright (c) Microsoft Corporation. - * Licensed under the MIT License. - * @format - */ - -import * as React from 'react'; -import { findNodeHandle } from 'react-native'; - -import { useViewCommandFocus } from '@fluentui-react-native/interactive-hooks'; -import type { IUseStyling } from '@uifabricshared/foundation-composable'; -import { composable } from '@uifabricshared/foundation-composable'; -import { mergeSettings } from '@uifabricshared/foundation-settings'; - -import type { FocusZoneProps, FocusZoneSlotProps, FocusZoneType } from './FocusZone.types'; -import RCTFocusZone from './FocusZoneNativeComponent'; - -const filterOutComponentRef = (props: T): T => { - if (props && typeof props === 'object' && 'componentRef' in props) { - const { componentRef: _componentRef, ...rest } = props as Record; - return rest as T; - } - return props; -}; - -export const FocusZone = composable({ - usePrepareProps: (userProps: FocusZoneProps, useStyling: IUseStyling) => { - const { componentRef, defaultTabbableElement, isCircularNavigation, ...rest } = userProps; - - const ftzRef = useViewCommandFocus(componentRef); - - const [targetFirstFocus, setTargetFirstFocus] = React.useState(undefined); - React.useLayoutEffect(() => { - if (typeof defaultTabbableElement === 'string') { - setTargetFirstFocus(defaultTabbableElement); - } else if (defaultTabbableElement?.current) { - setTargetFirstFocus(findNodeHandle(defaultTabbableElement.current)); - } else { - setTargetFirstFocus(undefined); - } - }, [defaultTabbableElement]); - - return { - slotProps: mergeSettings(useStyling(userProps), { - root: { - navigateAtEnd: isCircularNavigation ? 'NavigateWrap' : 'NavigateStopAtEnds', // let rest override - ...rest, - defaultTabbableElement: targetFirstFocus, - ref: ftzRef, - }, - }), - }; - }, - slots: { - root: { slotType: RCTFocusZone, filter: filterOutComponentRef }, - }, -}); diff --git a/packages/components/FocusZone/src/FocusZone.tsx b/packages/components/FocusZone/src/FocusZone.tsx new file mode 100644 index 00000000000..bee92de2e98 --- /dev/null +++ b/packages/components/FocusZone/src/FocusZone.tsx @@ -0,0 +1,47 @@ +/** @jsxImportSource @fluentui-react-native/framework-base */ +import * as React from 'react'; +import { findNodeHandle } from 'react-native'; + +import { directComponent, mergeProps, phasedComponent } from '@fluentui-react-native/framework-base'; + +import type { FocusZoneProps } from './FocusZone.types'; +import { focusZoneName } from './FocusZone.types'; +import NativeFocusZone from './FocusZoneNativeComponent'; + +/** + * Renders the native FocusZone without applying theme or appearance defaults. + */ +export const FocusZone = phasedComponent((props) => { + const { defaultTabbableElement } = props; + const [nativeDefaultTabbableElement, setNativeDefaultTabbableElement] = React.useState(); + + React.useLayoutEffect(() => { + if (typeof defaultTabbableElement === 'string') { + setNativeDefaultTabbableElement(defaultTabbableElement); + } else if (defaultTabbableElement?.current) { + setNativeDefaultTabbableElement(findNodeHandle(defaultTabbableElement.current) ?? undefined); + } else { + setNativeDefaultTabbableElement(undefined); + } + }, [defaultTabbableElement]); + + return directComponent((renderProps) => { + const { + componentRef, + defaultTabbableElement: _defaultTabbableElement, + isCircularNavigation, + ...nativeProps + } = mergeProps(props, renderProps); + + return ( + + ); + }); +}); + +FocusZone.displayName = focusZoneName; diff --git a/packages/components/FocusZone/src/FocusZone.types.ts b/packages/components/FocusZone/src/FocusZone.types.ts index 55557ee46c8..418666e6998 100644 --- a/packages/components/FocusZone/src/FocusZone.types.ts +++ b/packages/components/FocusZone/src/FocusZone.types.ts @@ -1,25 +1,20 @@ import type * as React from 'react'; - -import type { IViewProps } from '@fluentui-react-native/adapters'; -import type { IFocusable } from '@fluentui-react-native/interactive-hooks'; -import type { IRenderData } from '@uifabricshared/foundation-composable'; +import type { View, ViewProps } from 'react-native'; export const focusZoneName = 'FocusZone'; -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -type EmptyInterface = {}; -export type FocusZoneState = EmptyInterface; +export type FocusZoneState = Record; -export type FocusZoneProps = IViewProps & { +export interface FocusZoneProps extends ViewProps { /** - * A RefObject to access the IFocusable interface. Use this to access the public methods and properties of the component. + * A ref to the native FocusZone view. */ - componentRef?: React.RefObject; + componentRef?: React.Ref; /** * Optionally defines the initial tabbable element inside the FocusZone */ - defaultTabbableElement?: React.RefObject | string; + defaultTabbableElement?: React.RefObject | string; /** ** Defines which arrows to react to @@ -61,11 +56,6 @@ export type FocusZoneProps = IViewProps & { */ tabKeyNavigation?: FocusZoneTabNavigation; - /** - * Callback called when “focus” event triggered in FocusZone - */ - onFocus?: (e?: any) => void; - /** * @platform macos * By default, the Apple KeyView loop is calculated based on the UI tree hierarchy, accessibility properties, @@ -83,9 +73,10 @@ export type FocusZoneProps = IViewProps & { * of the MacOS KeyView loop mechanics. */ navigationOrderInRenderOrder?: boolean; -}; +} -export interface NativeProps extends Omit { +export interface NativeProps extends Omit { + defaultTabbableElement?: number | string; navigateAtEnd?: NavigateAtEnd; tabKeyNavigation?: FocusZoneTabNavigation; } @@ -107,14 +98,21 @@ export type FocusZoneTabNavigation = | 'NavigateStopAtEnds' /* Navigate the FZ with Tab. Stop navigation at ends */ | 'Normal'; /* Navigate the FZ with Tab. Don't trap focus, tabbing at ends moves you out */ -export type FocusZoneTokens = EmptyInterface; +/** @deprecated FocusZone is an unstyled primitive and does not resolve tokens. */ +export type FocusZoneTokens = Record; +/** @deprecated Use FocusZoneProps. */ export interface FocusZoneSlotProps { root: NativeProps; } -export type FocusZoneRenderData = IRenderData; +/** @deprecated FocusZone uses the phased primitive component pattern. */ +export interface FocusZoneRenderData { + slotProps?: FocusZoneSlotProps; + state?: FocusZoneState; +} +/** @deprecated Use FocusZoneProps. */ export interface FocusZoneType { props: FocusZoneProps; tokens: FocusZoneTokens; diff --git a/packages/components/FocusZone/src/FocusZoneNativeComponent.ts b/packages/components/FocusZone/src/FocusZoneNativeComponent.ts index b6c7cf2ae50..daa1e39599c 100644 --- a/packages/components/FocusZone/src/FocusZoneNativeComponent.ts +++ b/packages/components/FocusZone/src/FocusZoneNativeComponent.ts @@ -5,11 +5,8 @@ */ import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent'; -import type { ViewProps } from 'react-native'; -import type { WithDefault, UnsafeMixed } from 'react-native/Libraries/Types/CodegenTypes'; - -// Should be: -// import type { UnsafeObject } from 'react-native/Libraries/Types/CodegenTypes'; +import type { HostComponent, ViewProps } from 'react-native'; +import type { UnsafeMixed, WithDefault } from 'react-native/Libraries/Types/CodegenTypes'; export interface NativeProps extends ViewProps { navigateAtEnd?: WithDefault<'NavigateStopAtEnds' | 'NavigateWrap' | 'NavigateContinue', 'NavigateStopAtEnds'>; @@ -19,6 +16,11 @@ export interface NativeProps extends ViewProps { tabKeyNavigation?: WithDefault<'None' | 'NavigateWrap' | 'NavigateStopAtEnds' | 'Normal', 'None'>; disabled?: boolean; isTabNavigation?: boolean; + navigationOrderInRenderOrder?: boolean; } -export default codegenNativeComponent('RCTFocusZone'); +export type FocusZoneComponentType = HostComponent; + +export default codegenNativeComponent('FocusZone', { + paperComponentName: 'RCTFocusZone', +}) as FocusZoneComponentType; diff --git a/packages/components/FocusZone/src/__tests__/FocusZone.test.tsx b/packages/components/FocusZone/src/__tests__/FocusZone.test.tsx index e1ef917dea2..3c780e4a62a 100644 --- a/packages/components/FocusZone/src/__tests__/FocusZone.test.tsx +++ b/packages/components/FocusZone/src/__tests__/FocusZone.test.tsx @@ -1,151 +1,124 @@ -import { act } from 'react'; -import { Button } from 'react-native'; +/** @jsxImportSource @fluentui-react-native/framework-base */ +import * as React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; -import * as renderer from 'react-test-renderer'; +import { isPhasedComponent, useSlot } from '@fluentui-react-native/framework-base'; +import type { SlotProp } from '@fluentui-react-native/framework-base'; +import { render } from '@testing-library/react-native'; -import { FocusZone } from '..'; +import { FocusZone } from '../FocusZone'; -const onPress = () => { - return; +jest.mock('react-native/Libraries/ReactNative/RendererProxy', () => ({ + ...jest.requireActual('react-native/Libraries/ReactNative/RendererProxy'), + findNodeHandle: jest.fn(() => 42), +})); + +type FocusZoneSlotConsumerProps = { + focusZone: SlotProp; }; -describe('FocusZone No Props', () => { - it('No children', () => { - let component: renderer.ReactTestRenderer; - act(() => { - component = renderer.create(); - }); - expect(component!.toJSON()).toMatchSnapshot(); - }); +function FocusZoneSlotConsumer({ focusZone }: FocusZoneSlotConsumerProps) { + const FocusZoneSlot = useSlot(FocusZone, focusZone); + return ( + + Slot content + + ); +} - it('One child', () => { - let component: renderer.ReactTestRenderer; - act(() => { - component = renderer.create( - -