From ffd510a86fd8994b90256bad9228098923bd4b52 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Mon, 17 Aug 2026 11:26:11 -0700 Subject: [PATCH 1/5] move focus zone to primitive only implementation and add fabric implementation --- .changeset/calm-zones-focus.md | 5 + .../agentic-storybook-development/SKILL.md | 3 + .../agentic-components/storybook/README.md | 5 +- .../agentic-components/storybook/package.json | 2 + .../agentic-components/storybook/src/main.ts | 6 +- .../storybook/tsconfig.json | 3 + .../components/FocusZone/RCTFocusZone.podspec | 2 +- packages/components/FocusZone/SPEC.md | 39 + .../macos/RCTFocusZoneComponentView.h | 14 + .../macos/RCTFocusZoneComponentView.mm | 186 ++ packages/components/FocusZone/package.json | 21 +- .../FocusZone/src/FocusZone.stories.tsx | 258 +++ .../components/FocusZone/src/FocusZone.ts | 57 - .../components/FocusZone/src/FocusZone.tsx | 47 + .../FocusZone/src/FocusZone.types.ts | 38 +- .../FocusZone/src/FocusZoneNativeComponent.ts | 12 +- .../src/__tests__/FocusZone.test.tsx | 225 ++- .../src/__tests__/FocusZone.types.test.ts | 36 + .../__snapshots__/FocusZone.test.tsx.snap | 1516 ----------------- packages/components/FocusZone/tsconfig.json | 12 +- yarn.lock | 9 +- 21 files changed, 746 insertions(+), 1750 deletions(-) create mode 100644 .changeset/calm-zones-focus.md create mode 100644 packages/components/FocusZone/SPEC.md create mode 100644 packages/components/FocusZone/macos/RCTFocusZoneComponentView.h create mode 100644 packages/components/FocusZone/macos/RCTFocusZoneComponentView.mm create mode 100644 packages/components/FocusZone/src/FocusZone.stories.tsx delete mode 100644 packages/components/FocusZone/src/FocusZone.ts create mode 100644 packages/components/FocusZone/src/FocusZone.tsx create mode 100644 packages/components/FocusZone/src/__tests__/FocusZone.types.test.ts delete mode 100644 packages/components/FocusZone/src/__tests__/__snapshots__/FocusZone.test.tsx.snap diff --git a/.changeset/calm-zones-focus.md b/.changeset/calm-zones-focus.md new file mode 100644 index 00000000000..cc14bd70c0e --- /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 a macOS Fabric implementation alongside Paper. diff --git a/.github/skills/agentic-storybook-development/SKILL.md b/.github/skills/agentic-storybook-development/SKILL.md index 04b2a86b154..e49f67b062d 100644 --- a/.github/skills/agentic-storybook-development/SKILL.md +++ b/.github/skills/agentic-storybook-development/SKILL.md @@ -19,6 +19,9 @@ Work on the native Storybook application in `packages/agentic-components/storybo 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/README.md b/packages/agentic-components/storybook/README.md index fd4f0da48fe..7c0af969956 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` / @@ -95,6 +95,9 @@ 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 continues to use the platform-provided Windows implementation; its package-owned Fabric +component view is macOS-specific. + The Debug app always loads from Metro; `react-native-test-app` does not automatically fall back to an embedded bundle in Debug builds. To bundle, build, and launch a Release app that runs without Metro: diff --git a/packages/agentic-components/storybook/package.json b/packages/agentic-components/storybook/package.json index 2b40ffeafb5..1a01f2e8023 100644 --- a/packages/agentic-components/storybook/package.json +++ b/packages/agentic-components/storybook/package.json @@ -32,6 +32,7 @@ "@fluentui-react-native/components": "workspace:*", "@fluentui-react-native/default-theme": "workspace:*", "@fluentui-react-native/design": "workspace:*", + "@fluentui-react-native/focus-zone": "workspace:*", "@types/react": "~19.1.4", "react": "19.1.4", "react-native": "^0.81.6", @@ -72,6 +73,7 @@ "ignoreDependencies": [ "@fluentui-react-native/callout", "@fluentui-react-native/components", + "@fluentui-react-native/focus-zone", "react-native-svg" ] } 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/tsconfig.json b/packages/agentic-components/storybook/tsconfig.json index 2a30b128046..46063259bf3 100644 --- a/packages/agentic-components/storybook/tsconfig.json +++ b/packages/agentic-components/storybook/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../components/Callout/tsconfig.json" }, + { + "path": "../../components/FocusZone/tsconfig.json" + }, { "path": "../../agentic-design/tsconfig.json" }, 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..454a174aa8e --- /dev/null +++ b/packages/components/FocusZone/SPEC.md @@ -0,0 +1,39 @@ +# 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 and Win32 continue to use their platform-provided native FocusZone + implementations. +- 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..8f971304f27 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,6 +73,16 @@ "optional": true } }, + "codegenConfig": { + "name": "FRNFocusZoneSpec", + "type": "all", + "jsSrcsDir": "src", + "ios": { + "componentProvider": { + "FocusZone": "RCTFocusZoneComponentView" + } + } + }, "furn": { "jestPlatform": "win32" }, @@ -88,7 +96,6 @@ "core-ios", "core-macos", "core-windows", - "metro-react-native-babel-transformer", "react", "react-test-renderer" ] diff --git a/packages/components/FocusZone/src/FocusZone.stories.tsx b/packages/components/FocusZone/src/FocusZone.stories.tsx new file mode 100644 index 00000000000..6b91122e232 --- /dev/null +++ b/packages/components/FocusZone/src/FocusZone.stories.tsx @@ -0,0 +1,258 @@ +/** @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]} + > + 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, + ]} + > + {item} + + ))} + + + setFocusedItem('Outside after')} + onPress={() => afterRef.current?.focus()} + ref={afterRef} + style={({ pressed }) => [styles.outsideButton, pressed && styles.pressed]} + > + 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 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..24f2a1432e0 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,9 @@ 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('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( - -