diff --git a/.changeset/bright-callouts-appear.md b/.changeset/bright-callouts-appear.md new file mode 100644 index 0000000000..3efed8d72e --- /dev/null +++ b/.changeset/bright-callouts-appear.md @@ -0,0 +1,5 @@ +--- +"@fluentui-react-native/callout": patch +--- + +Add macOS and Windows Fabric component views and interactive Callout stories diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 4bf6a47dcc..03a7f7302b 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -10,6 +10,7 @@ "**/*.generated.*", "**/lib-commonjs/**", "**/dist/**", + "packages/components/Callout/windows/FRNCallout/codegen/**", "**/CHANGELOG.*", "**/CODE_OF_CONDUCT.md", "**/SECURITY.md", diff --git a/apps/fluent-tester/macos/Podfile.lock b/apps/fluent-tester/macos/Podfile.lock index 6c119ff1bf..3ec7f30870 100644 --- a/apps/fluent-tester/macos/Podfile.lock +++ b/apps/fluent-tester/macos/Podfile.lock @@ -2640,7 +2640,7 @@ SPEC CHECKSUMS: FBLazyVector: 3cde5aa3abeda29fd70560e5a3e3199e6add163f fmt: 4cf0c5ec5864511c96d8d4bd7b03c3c69040af06 FRNAvatar: 7c377a859feea236a20eb0413c2e73afcc21e0d2 - FRNCallout: f2acc41ab57051facfadcd7e3a6c68c98ddab0e4 + FRNCallout: bb597344d0051c47d12f37192910a2e145e40543 FRNCheckbox: 48dcf237f30dff0cb66ab1befa1401b72d2393df FRNMenuButton: f7d6ef3c81a6308fd00dc5f4241dcc6d5e5e63dd FRNRadioButton: c9421d2736512aa64bbf46517fe702e43013586a diff --git a/docs/pages/Components/Callout.md b/docs/pages/Components/Callout.md index bd59d18070..f5a4f11130 100644 --- a/docs/pages/Components/Callout.md +++ b/docs/pages/Components/Callout.md @@ -135,7 +135,6 @@ type DirectionalHint = | 'rightCenter' | 'rightBottomEdge' | 'bottomLeftEdge' - | 'bottonLeftEdge' // Deprecated misspelling retained for compatibility | 'bottomAutoEdge' | 'bottomCenter' | 'bottomRightEdge'; diff --git a/packages/agentic-components/storybook/README.md b/packages/agentic-components/storybook/README.md index 0cc13ac8a2..fd4f0da48f 100644 --- a/packages/agentic-components/storybook/README.md +++ b/packages/agentic-components/storybook/README.md @@ -1,8 +1,9 @@ # Agentic Components Storybook On-device [Storybook](https://storybook.js.org/) app (Storybook for React Native v10) for -`@fluentui-react-native/components`. It loads every `*.stories.(ts|tsx)` file from the -library source (`../src`) so new component stories appear automatically. +`@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. It runs in Storybook **liteMode**, which mocks out the heavy default on-device UI (`@storybook/react-native-ui`). This avoids the `react-native-reanimated` / @@ -19,8 +20,7 @@ selected while navigating between stories. ``` storybook/ - .rnstorybook/ Storybook config (main.ts, preview.tsx, index.tsx) - StorybookApp.tsx Root component -> renders the Storybook UI + src/ Storybook config, generated requires, and root component index.js AppRegistry entry app.json react-native-test-app manifest metro.config.js rnx-kit metro config wrapped with withStorybook (liteMode) @@ -31,11 +31,11 @@ storybook/ > `StorybookApp.tsx` is intentionally not named `App.tsx`: on a case-insensitive macOS > filesystem `App` collides with `app.json` during Metro resolution. -The `.rnstorybook/storybook.requires.ts` file is **generated** (git-ignored) from the +The `src/storybook.requires.ts` file is **generated** (git-ignored) from the `main.ts` stories glob by the `withStorybook` metro wrapper when Metro starts, or on demand via: ```sh -yarn workspace @fluentui-react-native/agentic-components-storybook storybook-generate +yarn workspace @fluentui-react-native/agentic-components-storybook prebuild ``` ## Running on macOS @@ -76,7 +76,8 @@ yarn pods:macos:update ## Running on Windows The Windows app also uses `react-native-test-app`. Its generated Win32 project uses React Native -Windows' New Architecture and Fabric renderer. +Windows 0.81's New Architecture and Fabric renderer. The Callout package is autolinked as a +Windows Fabric native library; its Paper implementation remains built into the platform. ```powershell # from this directory @@ -131,7 +132,7 @@ yarn storybook-server # WebSocket: ws://127.0.0.1:7007/ MCP: http://127.0.0. ``` Run it alongside `yarn start` + `yarn macos` or `yarn windows`. The on-device app connects to it automatically -(`.rnstorybook/index.tsx` calls `getStorybookUI({ enableWebsockets: true, host, port })`). +(`src/StorybookApp.tsx` calls `getStorybookUI({ enableWebsockets: true, host, port })`). - **WebSocket channel** (`ws://127.0.0.1:7007/`): agents connect and emit Storybook channel events to drive the app — e.g. `setCurrentStory` (`{ storyId }`) to switch story, and arg-update events @@ -153,5 +154,5 @@ Run it alongside `yarn start` + `yarn macos` or `yarn windows`. The on-device ap ## Writing stories Follow the package-level story authoring instructions in `../AGENTS.md`. Add a `*.stories.tsx` file next to its component -under `../src`; the `src/main.ts` glob discovers it automatically. See +under `../src`; standalone native package story globs are listed explicitly in `src/main.ts`. See `../src/components/button/button.stories.tsx` for the canonical higher-order component example. diff --git a/packages/agentic-components/storybook/metro.config.js b/packages/agentic-components/storybook/metro.config.js index c176671f01..c16eb7b282 100644 --- a/packages/agentic-components/storybook/metro.config.js +++ b/packages/agentic-components/storybook/metro.config.js @@ -40,7 +40,7 @@ const config = makeMetroConfig({ }); module.exports = withStorybook(config, { - configPath: path.resolve(__dirname, '.rnstorybook'), + configPath: path.resolve(__dirname, 'src'), // Lite mode mocks out the heavy default Storybook UI so we don't need react-native-reanimated, // react-native-gesture-handler, @gorhom/bottom-sheet or react-native-svg. liteMode: true, diff --git a/packages/agentic-components/storybook/package.json b/packages/agentic-components/storybook/package.json index 72849f7156..2b40ffeafb 100644 --- a/packages/agentic-components/storybook/package.json +++ b/packages/agentic-components/storybook/package.json @@ -28,6 +28,7 @@ "format": "fluentui-scripts format" }, "dependencies": { + "@fluentui-react-native/callout": "workspace:*", "@fluentui-react-native/components": "workspace:*", "@fluentui-react-native/default-theme": "workspace:*", "@fluentui-react-native/design": "workspace:*", @@ -44,8 +45,6 @@ "@react-native-community/cli": "^20.0.0", "@react-native-community/cli-platform-android": "^20.0.0", "@react-native-community/cli-platform-ios": "^20.0.0", - "@react-native-community/datetimepicker": "^9.1.0", - "@react-native-community/slider": "^5.0.0", "@react-native-windows/cli": "^0.81.0", "@react-native/babel-preset": "^0.81.0", "@react-native/metro-babel-transformer": "^0.81.0", @@ -71,6 +70,7 @@ "furn": { "knip": { "ignoreDependencies": [ + "@fluentui-react-native/callout", "@fluentui-react-native/components", "react-native-svg" ] diff --git a/packages/agentic-components/storybook/react-native.config.js b/packages/agentic-components/storybook/react-native.config.js index 91a925914b..e4f19eb331 100644 --- a/packages/agentic-components/storybook/react-native.config.js +++ b/packages/agentic-components/storybook/react-native.config.js @@ -17,14 +17,7 @@ const project = (() => { module.exports = { dependencies: { - // These optional Storybook control dependencies only provide legacy UWP projects. Linking - // them into the WinUI 3 Fabric app fails, while liteMode does not require their native views. - '@react-native-community/datetimepicker': { - platforms: { - windows: null, - }, - }, - '@react-native-community/slider': { + 'react-native-svg': { platforms: { windows: null, }, diff --git a/packages/agentic-components/storybook/src/main.ts b/packages/agentic-components/storybook/src/main.ts index 35862c234e..03b90c569a 100644 --- a/packages/agentic-components/storybook/src/main.ts +++ b/packages/agentic-components/storybook/src/main.ts @@ -3,11 +3,11 @@ import type { StorybookConfig } from '@storybook/react-native'; /** * Storybook configuration for the agentic-components on-device app. * - * Stories are loaded directly from the sibling library source (`../../src`) so any - * `*.stories.(ts|tsx)` added to a component automatically shows up here. + * Stories are loaded from the sibling agentic library and standalone native packages + * that are linked into this application. */ const main: StorybookConfig = { - stories: ['../../src/**/*.stories.?(ts|tsx)'], + stories: ['../../src/**/*.stories.?(ts|tsx)', '../../../components/Callout/src/**/*.stories.?(ts|tsx)'], addons: [], deviceAddons: ['@storybook/addon-ondevice-controls', '@storybook/addon-ondevice-actions'], }; diff --git a/packages/agentic-components/storybook/storybook-server.cjs b/packages/agentic-components/storybook/storybook-server.cjs index 54363bf0ca..e349a5053d 100644 --- a/packages/agentic-components/storybook/storybook-server.cjs +++ b/packages/agentic-components/storybook/storybook-server.cjs @@ -3,7 +3,7 @@ // This lets external agents drive the running on-device Storybook app: // - WebSocket channel (ws://:/): select the current story, read/update control // args, and receive Storybook channel events. The on-device app connects to this server -// (configured via getStorybookUI({ enableWebsockets: true, host, port }) in .rnstorybook/index.tsx). +// (configured via getStorybookUI({ enableWebsockets: true, host, port }) in src/StorybookApp.tsx). // - MCP endpoint (http://:/mcp): an MCP server for AI agents to query story / // component documentation and metadata (enabled via experimental_mcp). // @@ -21,7 +21,7 @@ const port = Number(process.env.STORYBOOK_WS_PORT) || 7007; createChannelServer({ host, port, - configPath: path.resolve(__dirname, '.rnstorybook'), + configPath: path.resolve(__dirname, 'src'), websockets: true, experimental_mcp: true, keepNodeProcessAlive: true, diff --git a/packages/agentic-components/storybook/tsconfig.json b/packages/agentic-components/storybook/tsconfig.json index e6e5aab050..2a30b12804 100644 --- a/packages/agentic-components/storybook/tsconfig.json +++ b/packages/agentic-components/storybook/tsconfig.json @@ -14,6 +14,9 @@ "include": ["index.js", "src", "app.json"], "exclude": ["node_modules", "dist", ".cache"], "references": [ + { + "path": "../../components/Callout/tsconfig.json" + }, { "path": "../../agentic-design/tsconfig.json" }, diff --git a/packages/components/Callout/FRNCallout.podspec b/packages/components/Callout/FRNCallout.podspec index 5d024a9830..5a46aedb2a 100644 --- a/packages/components/Callout/FRNCallout.podspec +++ b/packages/components/Callout/FRNCallout.podspec @@ -16,7 +16,7 @@ Pod::Spec.new do |s| s.swift_version = "5" s.osx.deployment_target = "10.15" - s.osx.source_files = "macos/*.{swift,h,m}" + s.osx.source_files = "macos/*.{swift,h,m,mm}" s.dependency 'React' diff --git a/packages/components/Callout/NuGet.config b/packages/components/Callout/NuGet.config new file mode 100644 index 0000000000..fe459fedd6 --- /dev/null +++ b/packages/components/Callout/NuGet.config @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/components/Callout/SPEC.md b/packages/components/Callout/SPEC.md index bd139cf204..92fd3b3497 100644 --- a/packages/components/Callout/SPEC.md +++ b/packages/components/Callout/SPEC.md @@ -26,7 +26,9 @@ CocoaPods integration. ## Platform behavior - macOS presents children in a native popup window and supports window focus - commands. + commands through both Paper and Fabric component registrations. +- Windows uses the platform's built-in Paper `RCTCallout` and supplies a + package-owned Fabric registration for React Native Windows 0.81 and newer. - Win32 supplies the platform `RCTCallout` implementation and supports native dismissal, pointer capture, beak, and focus-restoration behavior. - Unsupported native behavior remains platform-defined rather than being diff --git a/packages/components/Callout/macos/CalloutView.swift b/packages/components/Callout/macos/CalloutView.swift index 06db7024ca..72d40ab945 100644 --- a/packages/components/Callout/macos/CalloutView.swift +++ b/packages/components/Callout/macos/CalloutView.swift @@ -46,17 +46,11 @@ open class CalloutView: RCTView, CalloutWindowLifeCycleDelegate { public weak var bridge: RCTBridge? - private init() { - super.init(frame: .zero) - } + @objc public override init(frame frameRect: NSRect) { + super.init(frame: frameRect) - public required init?(coder: NSCoder) { - preconditionFailure() - } - - convenience init(bridge: RCTBridge) { - self.init() - self.bridge = bridge + backgroundColor = .clear + borderColor = .clear // Listens for mouse clicks in the main menu bar while callout is shown NotificationCenter.default.addObserver( @@ -66,6 +60,15 @@ open class CalloutView: RCTView, CalloutWindowLifeCycleDelegate { object: nil) } + public required init?(coder: NSCoder) { + preconditionFailure() + } + + convenience init(bridge: RCTBridge) { + self.init(frame: .zero) + self.bridge = bridge + } + public override func viewDidMoveToWindow() { super.viewDidMoveToWindow() if (window != nil) { @@ -84,6 +87,36 @@ open class CalloutView: RCTView, CalloutWindowLifeCycleDelegate { } } + // MARK: Fabric interface + + @objc public var contentProxyView: NSView { + return proxyView + } + + @objc public func setAnchorView(_ view: NSView?) { + anchorView = view + updateCalloutFrameToAnchor() + } + + @objc public func mountContentSubview(_ subview: NSView, at index: Int) { + if index >= proxyView.subviews.count { + proxyView.addSubview(subview) + } else { + proxyView.addSubview(subview, positioned: .below, relativeTo: proxyView.subviews[index]) + } + } + + @objc public func unmountContentSubview(_ subview: NSView) { + if subview.superview == proxyView { + subview.removeFromSuperview() + } + } + + @objc public func updateContentSize(_ size: NSSize) { + proxyView.frame = NSRect(origin: .zero, size: size) + updateCalloutFrameToAnchor() + } + // MARK: RCTComponent Overrides public override func insertReactSubview(_ subview: NSView!, at atIndex: Int) { @@ -362,10 +395,7 @@ open class CalloutView: RCTView, CalloutWindowLifeCycleDelegate { private func onDismissCallout() { if let onDismiss = onDismiss { - guard let reactTag = reactTag else { - preconditionFailure("React Tag missing") - } - let event: [AnyHashable: Any] = ["target": reactTag] + let event: [AnyHashable: Any] = ["target": reactTag ?? NSNumber(value: 0)] onDismiss(event) } } @@ -393,10 +423,12 @@ open class CalloutView: RCTView, CalloutWindowLifeCycleDelegate { * We can't directly call touchHandler.attach(to:) because `visualEffectView` is not an RCTUIView. * We get around this limitation by just replicating what `attach` did internally: add a gestureRecognizer. */ - guard let touchHandler = RCTTouchHandler(bridge: bridge) else { - preconditionFailure("Callout could not create RCTTouchHandler") + if let bridge = bridge { + guard let touchHandler = RCTTouchHandler(bridge: bridge) else { + preconditionFailure("Callout could not create RCTTouchHandler") + } + visualEffectView.addGestureRecognizer(touchHandler) } - visualEffectView.addGestureRecognizer(touchHandler) return visualEffectView }() diff --git a/packages/components/Callout/macos/FRNCalloutManager.m b/packages/components/Callout/macos/FRNCalloutManager.m index fe4e132ec3..16eaf75a55 100644 --- a/packages/components/Callout/macos/FRNCalloutManager.m +++ b/packages/components/Callout/macos/FRNCalloutManager.m @@ -29,7 +29,6 @@ + (NSRect)screenRect:(id)json @"rightCenter": @(NSRectEdgeMaxX), @"rightBottomEdge": @(NSRectEdgeMaxX), @"bottomLeftEdge": @(NSRectEdgeMinY), - @"bottonLeftEdge": @(NSRectEdgeMinY), // Add both the correct and incorrect spellings during the transition period @"bottomAutoEdge": @(NSRectEdgeMinY), @"bottomCenter": @(NSRectEdgeMinY), @"bottomRightEdge": @(NSRectEdgeMinY), diff --git a/packages/components/Callout/macos/RCTCalloutComponentView.h b/packages/components/Callout/macos/RCTCalloutComponentView.h new file mode 100644 index 0000000000..4e1566ea2c --- /dev/null +++ b/packages/components/Callout/macos/RCTCalloutComponentView.h @@ -0,0 +1,12 @@ +#ifdef RCT_NEW_ARCH_ENABLED + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface RCTCalloutComponentView : RCTViewComponentView +@end + +NS_ASSUME_NONNULL_END + +#endif diff --git a/packages/components/Callout/macos/RCTCalloutComponentView.mm b/packages/components/Callout/macos/RCTCalloutComponentView.mm new file mode 100644 index 0000000000..edc776a57c --- /dev/null +++ b/packages/components/Callout/macos/RCTCalloutComponentView.mm @@ -0,0 +1,221 @@ +#ifdef RCT_NEW_ARCH_ENABLED + +#import "RCTCalloutComponentView.h" + +#import +#import +#import +#import + +#import +#import +#import +#import +#import + +#import "FRNCallout-Swift.h" + +using namespace facebook::react; + +namespace facebook::react { + +extern const char CalloutComponentName[] = "Callout"; + +using CalloutShadowNode = ConcreteViewShadowNode< + CalloutComponentName, + RCTCalloutProps, + RCTCalloutEventEmitter, + RCTCalloutState>; +using CalloutComponentDescriptor = ConcreteComponentDescriptor; + +} // namespace facebook::react + +static NSRectEdge RCTNSRectEdgeFromDirectionalHint(RCTCalloutDirectionalHint hint) +{ + switch (hint) { + case RCTCalloutDirectionalHint::LeftTopEdge: + case RCTCalloutDirectionalHint::LeftCenter: + case RCTCalloutDirectionalHint::LeftBottomEdge: + return NSRectEdgeMinX; + case RCTCalloutDirectionalHint::TopLeftEdge: + case RCTCalloutDirectionalHint::TopAutoEdge: + case RCTCalloutDirectionalHint::TopCenter: + case RCTCalloutDirectionalHint::TopRightEdge: + return NSRectEdgeMaxY; + case RCTCalloutDirectionalHint::RightTopEdge: + case RCTCalloutDirectionalHint::RightCenter: + case RCTCalloutDirectionalHint::RightBottomEdge: + return NSRectEdgeMaxX; + case RCTCalloutDirectionalHint::BottomLeftEdge: + case RCTCalloutDirectionalHint::BottomAutoEdge: + case RCTCalloutDirectionalHint::BottomCenter: + case RCTCalloutDirectionalHint::BottomRightEdge: + return NSRectEdgeMinY; + } +} + +static void RCTApplyCalloutAppearance( + FRNCalloutView *calloutView, + const RCTCalloutProps &props, + const LayoutMetrics &layoutMetrics) +{ + const auto borderMetrics = props.resolveBorderMetrics(layoutMetrics); + calloutView.backgroundColor = RCTUIColorFromSharedColor(props.backgroundColor) ?: NSColor.clearColor; + calloutView.borderColor = RCTUIColorFromSharedColor(borderMetrics.borderColors.left) ?: NSColor.clearColor; + calloutView.borderWidth = borderMetrics.borderWidths.left; + calloutView.borderRadius = borderMetrics.borderRadii.topLeft.horizontal; + [calloutView setNeedsDisplay:YES]; +} + +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 RCTCalloutComponentView () +@end + +@implementation RCTCalloutComponentView { + FRNCalloutView *_calloutView; + NSInteger _targetTag; + RCTSurfaceTouchHandler *_touchHandler; +} + ++ (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; + + // This view manages content mounted in the Callout window and must not render in the React surface. + self.hidden = YES; + + _calloutView = [[FRNCalloutView alloc] initWithFrame:self.bounds]; + + __weak RCTCalloutComponentView *weakSelf = self; + _calloutView.onShow = ^(__unused NSDictionary *event) { + [weakSelf emitOnShow]; + }; + _calloutView.onDismiss = ^(__unused NSDictionary *event) { + [weakSelf emitOnDismiss]; + }; + + _touchHandler = [RCTSurfaceTouchHandler new]; + [_touchHandler attachToView:_calloutView.contentProxyView]; + + self.contentView = _calloutView; + } + return self; +} + +- (void)mountChildComponentView:(RCTUIView *)childComponentView index:(NSInteger)index +{ + [_calloutView mountContentSubview:childComponentView at:index]; +} + +- (void)unmountChildComponentView:(RCTUIView *)childComponentView index:(__unused NSInteger)index +{ + [_calloutView unmountContentSubview:childComponentView]; +} + +- (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &)oldProps +{ + const auto &newProps = *std::static_pointer_cast(props); + + _calloutView.directionalHint = RCTNSRectEdgeFromDirectionalHint(newProps.directionalHint); + _calloutView.setInitialFocus = newProps.setInitialFocus; + + const auto &rect = newProps.anchorRect; + _calloutView.anchorRect = NSMakeRect(rect.screenX, rect.screenY, rect.width, rect.height); + + _targetTag = 0; + if (newProps.target.isNumber()) { + _targetTag = (NSInteger)newProps.target.asDouble(); + } + [self updateAnchorView]; + RCTApplyCalloutAppearance(_calloutView, newProps, _layoutMetrics); + + [super updateProps:props oldProps:oldProps]; +} + +- (void)viewDidMoveToWindow +{ + [super viewDidMoveToWindow]; + [self updateAnchorView]; +} + +- (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics + oldLayoutMetrics:(const LayoutMetrics &)oldLayoutMetrics +{ + [super updateLayoutMetrics:layoutMetrics oldLayoutMetrics:oldLayoutMetrics]; + + const auto size = layoutMetrics.frame.size; + [_calloutView updateContentSize:NSMakeSize(size.width, size.height)]; + + const auto &props = *std::static_pointer_cast(_props); + RCTApplyCalloutAppearance(_calloutView, props, layoutMetrics); +} + +- (void)prepareForRecycle +{ + [super prepareForRecycle]; + _targetTag = 0; + [_calloutView setAnchorView:nil]; +} + +- (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args +{ + RCTRCTCalloutHandleCommand(self, commandName, args); +} + +- (void)focusWindow +{ + [_calloutView focusWindow]; +} + +- (void)blurWindow +{ + [_calloutView blurWindow]; +} + +- (void)updateAnchorView +{ + RCTPlatformView *rootView = self.window.contentView; + [_calloutView setAnchorView:_targetTag > 0 && rootView ? RCTFindComponentViewWithTag(rootView, _targetTag) : nil]; +} + +- (void)emitOnShow +{ + if (_eventEmitter) { + std::static_pointer_cast(_eventEmitter)->onShow({.target = 0}); + } +} + +- (void)emitOnDismiss +{ + if (_eventEmitter) { + std::static_pointer_cast(_eventEmitter)->onDismiss({.target = 0}); + } +} + +@end + +#endif diff --git a/packages/components/Callout/package.json b/packages/components/Callout/package.json index 5e19df2b44..4e7769e655 100644 --- a/packages/components/Callout/package.json +++ b/packages/components/Callout/package.json @@ -76,12 +76,30 @@ }, "codegenConfig": { "name": "FRNCalloutSpec", - "type": "components", - "jsSrcsDir": "lib" + "type": "all", + "jsSrcsDir": "src", + "ios": { + "componentProvider": { + "Callout": "RCTCalloutComponentView" + } + }, + "windows": { + "generators": "componentsWindows", + "namespace": "FRNCalloutCodegen", + "outputDirectory": "windows/FRNCallout/codegen", + "separateDataTypes": true + } }, "furn": { "jestPlatform": "win32" }, + "react-native-windows": { + "init-windows": { + "name": "FRNCallout", + "namespace": "FRNCallout", + "template": "cpp-lib" + } + }, "rnx-kit": { "kitType": "library", "alignDeps": { diff --git a/packages/components/Callout/src/Callout.stories.tsx b/packages/components/Callout/src/Callout.stories.tsx new file mode 100644 index 0000000000..cec96831ad --- /dev/null +++ b/packages/components/Callout/src/Callout.stories.tsx @@ -0,0 +1,269 @@ +/** @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 { Callout } from './Callout'; +import type { CalloutHandle, CalloutProps, DirectionalHint } from './Callout.types'; + +const directionalHints: readonly DirectionalHint[] = [ + 'leftTopEdge', + 'leftCenter', + 'leftBottomEdge', + 'topLeftEdge', + 'topAutoEdge', + 'topCenter', + 'topRightEdge', + 'rightTopEdge', + 'rightCenter', + 'rightBottomEdge', + 'bottomLeftEdge', + 'bottomAutoEdge', + 'bottomCenter', + 'bottomRightEdge', +]; + +type CalloutExampleProps = CalloutProps & { + defaultVisible?: boolean; + showWindowCommands?: boolean; +}; + +const CalloutExample = ({ defaultVisible = false, onDismiss, onShow, showWindowCommands = false, ...props }: CalloutExampleProps) => { + const anchorRef = React.useRef(null); + const calloutRef = React.useRef(null); + const [visible, setVisible] = React.useState(defaultVisible); + const [status, setStatus] = React.useState(defaultVisible ? 'Opening' : 'Closed'); + + const dismiss = React.useCallback(() => { + setVisible(false); + setStatus('Dismissed'); + onDismiss?.(); + }, [onDismiss]); + + const show = React.useCallback(() => { + setStatus('Shown'); + onShow?.(); + }, [onShow]); + + return ( + + + { + setVisible((current) => !current); + setStatus(visible ? 'Closed' : 'Opening'); + }} + ref={anchorRef} + style={({ pressed }) => [styles.trigger, pressed && styles.triggerPressed]} + > + {visible ? 'Close callout' : 'Open callout'} + + + + Native window: {status} + + {visible && ( + + + Fabric Callout + This content is hosted in a separate native macOS window. + {showWindowCommands && ( + + calloutRef.current?.focusWindow()} style={styles.command}> + Focus window + + calloutRef.current?.blurWindow()} style={styles.command}> + Blur window + + + )} + + + )} + + ); +}; + +type Placement = { + hint: DirectionalHint; + label: string; +}; + +const placements: readonly Placement[] = [ + { hint: 'topCenter', label: 'Above' }, + { hint: 'rightCenter', label: 'Right' }, + { hint: 'bottomCenter', label: 'Below' }, + { hint: 'leftCenter', label: 'Left' }, +]; + +const PlacementExample = (props: CalloutProps) => { + const aboveRef = React.useRef(null); + const rightRef = React.useRef(null); + const belowRef = React.useRef(null); + const leftRef = React.useRef(null); + const targetRefs = [aboveRef, rightRef, belowRef, leftRef] as const; + const [selectedIndex, setSelectedIndex] = React.useState(); + + return ( + + {placements.map(({ hint, label }, index) => ( + + setSelectedIndex(index)} + ref={targetRefs[index]} + style={({ pressed }) => [styles.trigger, pressed && styles.triggerPressed]} + > + {label} + + + ))} + {selectedIndex !== undefined && ( + setSelectedIndex(undefined)} + target={targetRefs[selectedIndex]} + > + + {placements[selectedIndex].label} + Click outside the native window to dismiss it. + + + )} + + ); +}; + +const meta: Meta = { + title: 'Primitives/Callout', + component: Callout, + args: { + accessibilityLabel: 'Callout example', + accessibilityRole: 'dialog', + backgroundColor: '#ffffff', + borderColor: '#d1d1d1', + borderRadius: 8, + borderWidth: 1, + directionalHint: 'bottomCenter', + maxWidth: 320, + setInitialFocus: false, + }, + argTypes: { + backgroundColor: { control: 'color' }, + borderColor: { control: 'color' }, + borderRadius: { control: { type: 'number', min: 0, max: 24, step: 1 } }, + borderWidth: { control: { type: 'number', min: 0, max: 8, step: 1 } }, + directionalHint: { control: 'select', options: directionalHints }, + maxHeight: { control: { type: 'number', min: 80, max: 600, step: 20 } }, + maxWidth: { control: { type: 'number', min: 120, max: 600, step: 20 } }, + minWidth: { control: { type: 'number', min: 80, max: 400, step: 20 } }, + setInitialFocus: { control: 'boolean' }, + }, + parameters: { + docs: { + description: { + component: + 'Callout is an unstyled native primitive that presents React content in a transient window positioned relative to a target.', + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => , +}; + +export const Placement: Story = { + render: (args) => , + parameters: { + docs: { + description: { + story: 'Each trigger requests one of the four macOS placement edges. The native Callout may flip or slide to remain on screen.', + }, + }, + }, +}; + +export const WindowCommands: Story = { + args: { + setInitialFocus: true, + }, + render: (args) => , + parameters: { + docs: { + description: { + story: 'The content buttons exercise Fabric touch handling and the imperative focusWindow and blurWindow native commands.', + }, + }, + }, +}; + +const styles = StyleSheet.create({ + body: { + color: '#424242', + marginTop: 6, + }, + calloutContent: { + padding: 16, + width: 280, + }, + command: { + backgroundColor: '#f5f5f5', + borderColor: '#d1d1d1', + borderRadius: 4, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 8, + }, + commandRow: { + flexDirection: 'row', + gap: 8, + marginTop: 16, + }, + heading: { + color: '#242424', + fontSize: 16, + fontWeight: '600', + }, + placementAnchor: { + margin: 32, + }, + placementStory: { + alignItems: 'center', + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'center', + minHeight: 300, + width: 520, + }, + status: { + color: '#616161', + marginTop: 12, + }, + story: { + alignItems: 'center', + justifyContent: 'center', + minHeight: 300, + width: 520, + }, + trigger: { + backgroundColor: '#0f6cbd', + borderRadius: 4, + paddingHorizontal: 16, + paddingVertical: 10, + }, + triggerPressed: { + backgroundColor: '#115ea3', + }, + triggerText: { + color: '#ffffff', + fontWeight: '600', + }, +}); diff --git a/packages/components/Callout/src/Callout.types.ts b/packages/components/Callout/src/Callout.types.ts index dec03cec2c..73262a52e2 100644 --- a/packages/components/Callout/src/Callout.types.ts +++ b/packages/components/Callout/src/Callout.types.ts @@ -15,7 +15,6 @@ export type DirectionalHint = | 'rightCenter' | 'rightBottomEdge' | 'bottomLeftEdge' - | 'bottonLeftEdge' | 'bottomAutoEdge' | 'bottomCenter' | 'bottomRightEdge'; diff --git a/packages/components/Callout/src/CalloutNativeComponent.ts b/packages/components/Callout/src/CalloutNativeComponent.ts index c996c84e4f..4ea7bd935a 100644 --- a/packages/components/Callout/src/CalloutNativeComponent.ts +++ b/packages/components/Callout/src/CalloutNativeComponent.ts @@ -50,11 +50,10 @@ export interface NativeProps extends ViewProps { | 'rightCenter' | 'rightBottomEdge' | 'bottomLeftEdge' - | 'bottonLeftEdge' // Typo in the original code, should be 'bottomLeftEdge' | 'bottomAutoEdge' | 'bottomCenter' | 'bottomRightEdge', - 'bottonLeftEdge' + 'bottomLeftEdge' >; target?: UnsafeMixed; } diff --git a/packages/components/Callout/tsconfig.json b/packages/components/Callout/tsconfig.json index 256f00585b..d13fbc90df 100644 --- a/packages/components/Callout/tsconfig.json +++ b/packages/components/Callout/tsconfig.json @@ -7,6 +7,7 @@ "tsBuildInfoFile": ".cache/tsconfig.tsbuildinfo" }, "include": ["src"], + "exclude": ["**/*.stories.ts", "**/*.stories.tsx"], "references": [ { "path": "../../framework-base/tsconfig.json" diff --git a/packages/components/Callout/windows/.gitignore b/packages/components/Callout/windows/.gitignore new file mode 100644 index 0000000000..0fd0acfc6d --- /dev/null +++ b/packages/components/Callout/windows/.gitignore @@ -0,0 +1,46 @@ +*AppPackages* +*BundleArtifacts* + +#OS junk files +[Tt]humbs.db +*.DS_Store + +#Visual Studio files +*.[Oo]bj +*.user +*.aps +*.pch +*.vspscc +*.vssscc +*_i.c +*_p.c +*.ncb +*.suo +*.tlb +*.tlh +*.bak +*.[Cc]ache +*.ilk +*.log +*.lib +*.sbr +*.sdf +*.opensdf +*.opendb +*.unsuccessfulbuild +ipch/ +[Oo]bj/ +[Bb]in +[Dd]ebug*/ +[Rr]elease*/ +Ankh.NoLoad +.vs/ +# Visual C++ cache files + +#Files generated by the VS build +**/Generated Files/** + +#Files generated by MS build +*.binlog +*.err +*.wrn diff --git a/packages/components/Callout/windows/ExperimentalFeatures.props b/packages/components/Callout/windows/ExperimentalFeatures.props new file mode 100644 index 0000000000..db10c59f9a --- /dev/null +++ b/packages/components/Callout/windows/ExperimentalFeatures.props @@ -0,0 +1,33 @@ + + + + + + true + + + true + + true + + + diff --git a/packages/components/Callout/windows/FRNCallout.sln b/packages/components/Callout/windows/FRNCallout.sln new file mode 100644 index 0000000000..56bae5e2b7 --- /dev/null +++ b/packages/components/Callout/windows/FRNCallout.sln @@ -0,0 +1,43 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.3.32929.385 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "FRNCallout", "FRNCallout\FRNCallout.vcxproj", "{3647410B-5E48-4EFE-A381-631BD3AF192F}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Debug|ARM64 = Debug|ARM64 + Release|x64 = Release|x64 + Release|x86 = Release|x86 + Release|ARM64 = Release|ARM64 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {3647410B-5E48-4EFE-A381-631BD3AF192F}.Debug|x64.ActiveCfg = Debug|x64 + {3647410B-5E48-4EFE-A381-631BD3AF192F}.Debug|x64.Build.0 = Debug|x64 + {3647410B-5E48-4EFE-A381-631BD3AF192F}.Debug|x64.Deploy.0 = Debug|x64 + {3647410B-5E48-4EFE-A381-631BD3AF192F}.Debug|x86.ActiveCfg = Debug|Win32 + {3647410B-5E48-4EFE-A381-631BD3AF192F}.Debug|x86.Build.0 = Debug|Win32 + {3647410B-5E48-4EFE-A381-631BD3AF192F}.Debug|x86.Deploy.0 = Debug|Win32 + {3647410B-5E48-4EFE-A381-631BD3AF192F}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {3647410B-5E48-4EFE-A381-631BD3AF192F}.Debug|ARM64.Build.0 = Debug|ARM64 + {3647410B-5E48-4EFE-A381-631BD3AF192F}.Debug|ARM64.Deploy.0 = Debug|ARM64 + {3647410B-5E48-4EFE-A381-631BD3AF192F}.Release|x64.ActiveCfg = Release|x64 + {3647410B-5E48-4EFE-A381-631BD3AF192F}.Release|x64.Build.0 = Release|x64 + {3647410B-5E48-4EFE-A381-631BD3AF192F}.Release|x64.Deploy.0 = Release|x64 + {3647410B-5E48-4EFE-A381-631BD3AF192F}.Release|x86.ActiveCfg = Release|Win32 + {3647410B-5E48-4EFE-A381-631BD3AF192F}.Release|x86.Build.0 = Release|Win32 + {3647410B-5E48-4EFE-A381-631BD3AF192F}.Release|x86.Deploy.0 = Release|Win32 + {3647410B-5E48-4EFE-A381-631BD3AF192F}.Release|ARM64.ActiveCfg = Release|ARM64 + {3647410B-5E48-4EFE-A381-631BD3AF192F}.Release|ARM64.Build.0 = Release|ARM64 + {3647410B-5E48-4EFE-A381-631BD3AF192F}.Release|ARM64.Deploy.0 = Release|ARM64 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {D43FAD39-F619-437D-BB40-04A3982ACB6A} + EndGlobalSection +EndGlobal diff --git a/packages/components/Callout/windows/FRNCallout/CalloutComponentView.cpp b/packages/components/Callout/windows/FRNCallout/CalloutComponentView.cpp new file mode 100644 index 0000000000..8c16710a61 --- /dev/null +++ b/packages/components/Callout/windows/FRNCallout/CalloutComponentView.cpp @@ -0,0 +1,408 @@ +#include "pch.h" + +#include "CalloutComponentView.h" + +#ifdef RNW_NEW_ARCH + +#include "codegen/react/components/FRNCalloutSpec/RCTCallout.g.h" + +#include +#include +#include +#include +#include + +#include + +namespace winrt::FRNCallout { + +struct CalloutComponentView : winrt::implements, + FRNCalloutCodegen::BaseRCTCallout { + ~CalloutComponentView() { + CloseWindow(); + } + + void InitializePortal( + const winrt::Microsoft::ReactNative::Composition::PortalComponentView &portalComponentView) noexcept { + m_portal = portalComponentView; + m_reactContext = portalComponentView.ReactContext(); + + portalComponentView.Mounted([weakThis = get_weak()](auto &&, auto &&) { + if (auto strongThis = weakThis.get()) { + strongThis->m_mounted = true; + strongThis->QueueShow(); + } + }); + portalComponentView.Unmounted([weakThis = get_weak()](auto &&, auto &&) { + if (auto strongThis = weakThis.get()) { + strongThis->m_mounted = false; + strongThis->CloseWindow(); + } + }); + } + + void UpdateProps( + const winrt::Microsoft::ReactNative::ComponentView &view, + const winrt::com_ptr &newProps, + const winrt::com_ptr &oldProps) noexcept override { + BaseRCTCallout::UpdateProps(view, newProps, oldProps); + QueueShow(); + } + + void MountChildComponentView( + const winrt::Microsoft::ReactNative::ComponentView &, + const winrt::Microsoft::ReactNative::MountChildComponentViewArgs &args) noexcept override { + ResizeAndPosition(args.Child().LayoutMetrics()); + m_childLayoutMetricsToken = args.Child().LayoutMetricsChanged( + [weakThis = get_weak()](auto &&, const auto &eventArgs) { + if (auto strongThis = weakThis.get()) { + strongThis->ResizeAndPosition(eventArgs.NewLayoutMetrics()); + } + }); + QueueShow(); + } + + void UnmountChildComponentView( + const winrt::Microsoft::ReactNative::ComponentView &, + const winrt::Microsoft::ReactNative::UnmountChildComponentViewArgs &args) noexcept override { + if (m_childLayoutMetricsToken) { + args.Child().LayoutMetricsChanged(m_childLayoutMetricsToken); + m_childLayoutMetricsToken.value = 0; + } + } + + void HandleFocusWindowCommand() noexcept override { + if (m_window) { + auto hwnd = winrt::Microsoft::UI::GetWindowFromWindowId(m_window.AppWindow().Id()); + SetForegroundWindow(hwnd); + SetFocus(hwnd); + } + } + + void HandleBlurWindowCommand() noexcept override { + if (m_parentHwnd) { + SetForegroundWindow(m_parentHwnd); + SetFocus(m_parentHwnd); + } + } + + private: + void QueueShow() noexcept { + if (!m_mounted || m_showQueued || m_popup) { + return; + } + + m_showQueued = true; + m_reactContext.UIDispatcher().Post([weakThis = get_weak()] { + if (auto strongThis = weakThis.get()) { + strongThis->m_showQueued = false; + strongThis->Show(); + } + }); + } + + void Show() { + if (!m_mounted || m_popup) { + return; + } + + m_parentHwnd = + m_portal.as<::Microsoft::ReactNative::Composition::Experimental::IComponentViewInterop>()->GetHwndForParenting(); + m_previousWindowId = + winrt::Microsoft::ReactNative::ReactCoreInjection::GetTopLevelWindowId(m_reactContext.Properties().Handle()); + + m_popup = winrt::Microsoft::UI::Content::DesktopPopupSiteBridge::Create( + m_portal.Parent() + .as() + .Root() + .ReactNativeIsland() + .Island()); + m_window = winrt::Microsoft::ReactNative::ReactNativeWindow::CreateFromContentSiteBridgeAndIsland( + m_popup, winrt::Microsoft::ReactNative::ReactNativeIsland::CreatePortal(m_portal)); + m_window.ResizePolicy(winrt::Microsoft::ReactNative::ContentSizePolicy::None); + + auto presenter = winrt::Microsoft::UI::Windowing::OverlappedPresenter::Create(); + presenter.IsResizable(false); + presenter.IsMinimizable(false); + presenter.IsMaximizable(false); + presenter.SetBorderAndTitleBar(false, false); + m_window.AppWindow().SetPresenter(presenter); + + m_calloutHwnd = winrt::Microsoft::UI::GetWindowFromWindowId(m_window.AppWindow().Id()); + SetWindowSubclass(m_calloutHwnd, WindowSubclassProc, 1, reinterpret_cast(this)); + + m_closingToken = m_window.AppWindow().Closing([weakThis = get_weak()](auto &&, const auto &args) { + args.Cancel(true); + if (auto strongThis = weakThis.get()) { + strongThis->Dismiss(); + } + }); + + winrt::Microsoft::ReactNative::ReactCoreInjection::SetTopLevelWindowId( + m_reactContext.Properties().Handle(), + reinterpret_cast(winrt::Microsoft::UI::GetWindowFromWindowId(m_popup.WindowId()))); + + if (m_portal.ContentRoot().Children().Size()) { + ResizeAndPosition(m_portal.ContentRoot().Children().GetAt(0).LayoutMetrics()); + } + + m_popup.Show(); + if (Props() && Props()->setInitialFocus.value_or(false)) { + auto focusController = + winrt::Microsoft::UI::Input::InputFocusController::GetForIsland(m_window.ReactNativeIsland().Island()); + focusController.TrySetFocus(); + } + + if (auto eventEmitter = EventEmitter()) { + eventEmitter->onShow({.target = m_portal.Tag()}); + } + } + + void ResizeAndPosition(const winrt::Microsoft::ReactNative::LayoutMetrics &layoutMetrics) noexcept { + if (!m_window || layoutMetrics.Frame.Width <= 0 || layoutMetrics.Frame.Height <= 0) { + return; + } + + const auto scale = layoutMetrics.PointScaleFactor; + m_window.AppWindow().ResizeClient( + {static_cast(std::ceil(layoutMetrics.Frame.Width * scale)), + static_cast(std::ceil(layoutMetrics.Frame.Height * scale))}); + + auto origin = AnchorOrigin(); + auto x = static_cast(std::round(origin.X * scale)); + auto y = static_cast(std::round(origin.Y * scale)); + auto windowSize = m_window.AppWindow().Size(); + MONITORINFO monitorInfo{.cbSize = sizeof(MONITORINFO)}; + if (GetMonitorInfo(MonitorFromPoint({x, y}, MONITOR_DEFAULTTONEAREST), &monitorInfo)) { + auto props = Props(); + auto padding = static_cast(std::round((props ? props->minPadding.value_or(0) : 0) * scale)); + auto minX = monitorInfo.rcWork.left + padding; + auto maxX = monitorInfo.rcWork.right - windowSize.Width - padding; + auto minY = monitorInfo.rcWork.top + padding; + auto maxY = monitorInfo.rcWork.bottom - windowSize.Height - padding; + x = maxX >= minX ? std::clamp(x, minX, maxX) : minX; + y = maxY >= minY ? std::clamp(y, minY, maxY) : minY; + } + m_window.AppWindow().Move({x, y}); + } + + winrt::Windows::Foundation::Point AnchorOrigin() const noexcept { + auto props = Props(); + if (!props) { + return {}; + } + + winrt::Windows::Foundation::Rect anchor{}; + if (props->anchorRect) { + anchor = { + static_cast(props->anchorRect->screenX), + static_cast(props->anchorRect->screenY), + static_cast(props->anchorRect->width), + static_cast(props->anchorRect->height)}; + } else if ( + props->target && + (props->target.Type() == winrt::Microsoft::ReactNative::JSValueType::Int64 || + props->target.Type() == winrt::Microsoft::ReactNative::JSValueType::Double)) { + auto targetTag = props->target.Type() == winrt::Microsoft::ReactNative::JSValueType::Int64 + ? props->target.AsInt64() + : static_cast(props->target.AsDouble()); + auto target = winrt::Microsoft::ReactNative::Composition::CompositionUIService::ComponentFromReactTag( + m_reactContext.Handle(), targetTag); + if (target) { + auto scale = target.LayoutMetrics().PointScaleFactor; + auto current = target; + float x = 0; + float y = 0; + while (current) { + auto metrics = current.LayoutMetrics(); + x += metrics.Frame.X; + y += metrics.Frame.Y; + current = current.Parent(); + } + + POINT clientOrigin{0, 0}; + auto hwnd = + target.as<::Microsoft::ReactNative::Composition::Experimental::IComponentViewInterop>()->GetHwndForParenting(); + ClientToScreen(hwnd, &clientOrigin); + auto metrics = target.LayoutMetrics(); + anchor = { + clientOrigin.x / scale + x, + clientOrigin.y / scale + y, + metrics.Frame.Width, + metrics.Frame.Height}; + } + } + + auto gap = static_cast(props->gapSpace.value_or(0)); + auto windowSize = m_window.AppWindow().Size(); + auto scale = m_window.ReactNativeIsland().Island().RasterizationScale(); + auto width = windowSize.Width / scale; + auto height = windowSize.Height / scale; + auto hint = props->directionalHint.value_or("bottomCenter"); + + if (hint.starts_with("top")) { + auto x = hint.ends_with("LeftEdge") ? anchor.X + : hint.ends_with("RightEdge") ? anchor.X + anchor.Width - width + : anchor.X + (anchor.Width - width) / 2; + return {x, anchor.Y - height - gap}; + } + if (hint.starts_with("left")) { + auto y = hint.ends_with("TopEdge") ? anchor.Y + : hint.ends_with("BottomEdge") ? anchor.Y + anchor.Height - height + : anchor.Y + (anchor.Height - height) / 2; + return {anchor.X - width - gap, y}; + } + if (hint.starts_with("right")) { + auto y = hint.ends_with("TopEdge") ? anchor.Y + : hint.ends_with("BottomEdge") ? anchor.Y + anchor.Height - height + : anchor.Y + (anchor.Height - height) / 2; + return {anchor.X + anchor.Width + gap, y}; + } + auto x = hint.ends_with("LeftEdge") ? anchor.X + : hint.ends_with("RightEdge") ? anchor.X + anchor.Width - width + : anchor.X + (anchor.Width - width) / 2; + return {x, anchor.Y + anchor.Height + gap}; + } + + void Dismiss() noexcept { + const bool containedFocus = GetForegroundWindow() == m_calloutHwnd; + if (auto eventEmitter = EventEmitter()) { + eventEmitter->onDismiss({.target = m_portal.Tag()}); + if (Props() && Props()->onRestoreFocus) { + eventEmitter->onRestoreFocus({.target = m_portal.Tag(), .containsFocus = containedFocus}); + } + } + CloseWindow(); + } + + void CloseWindow() noexcept { + if (!m_popup) { + return; + } + + if (m_closingToken) { + m_window.AppWindow().Closing(m_closingToken); + m_closingToken.value = 0; + } + if (m_calloutHwnd) { + RemoveWindowSubclass(m_calloutHwnd, WindowSubclassProc, 1); + m_calloutHwnd = nullptr; + } + m_popup.Hide(); + m_window.Close(); + m_window = nullptr; + m_popup.Close(); + m_popup = nullptr; + + if (m_previousWindowId) { + winrt::Microsoft::ReactNative::ReactCoreInjection::SetTopLevelWindowId( + m_reactContext.Properties().Handle(), m_previousWindowId); + m_previousWindowId = 0; + } + HandleBlurWindowCommand(); + } + + static LRESULT CALLBACK WindowSubclassProc( + HWND hwnd, + UINT message, + WPARAM wParam, + LPARAM lParam, + UINT_PTR subclassId, + DWORD_PTR referenceData) noexcept { + auto callout = reinterpret_cast(referenceData); + if (message == WM_ACTIVATE && LOWORD(wParam) == WA_INACTIVE && callout && !callout->PreventsLightDismiss()) { + callout->m_reactContext.UIDispatcher().Post([weakThis = callout->get_weak()] { + if (auto strongThis = weakThis.get()) { + strongThis->Dismiss(); + } + }); + } + return DefSubclassProc(hwnd, message, wParam, lParam); + } + + bool PreventsLightDismiss() const noexcept { + auto props = Props(); + if (!props || !props->dismissBehaviors) { + return false; + } + return std::ranges::find(*props->dismissBehaviors, "preventDismissOnClickOutside") != props->dismissBehaviors->end(); + } + + winrt::Microsoft::ReactNative::ReactContext m_reactContext{nullptr}; + winrt::Microsoft::ReactNative::Composition::PortalComponentView m_portal{nullptr}; + winrt::Microsoft::UI::Content::DesktopPopupSiteBridge m_popup{nullptr}; + winrt::Microsoft::ReactNative::ReactNativeWindow m_window{nullptr}; + HWND m_parentHwnd{nullptr}; + HWND m_calloutHwnd{nullptr}; + uint64_t m_previousWindowId{0}; + bool m_mounted{false}; + bool m_showQueued{false}; + winrt::event_token m_closingToken{}; + winrt::event_token m_childLayoutMetricsToken{}; +}; + +} // namespace winrt::FRNCallout + +void RegisterCalloutComponentView( + winrt::Microsoft::ReactNative::IReactPackageBuilder const &packageBuilder) noexcept { + packageBuilder.as().AddViewComponent( + L"Callout", [](const winrt::Microsoft::ReactNative::IReactViewComponentBuilder &builder) noexcept { + auto compositionBuilder = + builder.as(); + + builder.SetCreateProps( + [](winrt::Microsoft::ReactNative::ViewProps props, + const winrt::Microsoft::ReactNative::IComponentProps &cloneFrom) noexcept { + return winrt::make(props, cloneFrom); + }); + builder.SetUpdatePropsHandler( + [](const winrt::Microsoft::ReactNative::ComponentView &view, + const winrt::Microsoft::ReactNative::IComponentProps &newProps, + const winrt::Microsoft::ReactNative::IComponentProps &oldProps) noexcept { + view.UserData() + .as() + ->UpdateProps( + view, + newProps ? newProps.as() : nullptr, + oldProps ? oldProps.as() : nullptr); + }); + compositionBuilder.SetUpdateLayoutMetricsHandler( + [](const winrt::Microsoft::ReactNative::ComponentView &view, + const winrt::Microsoft::ReactNative::LayoutMetrics &newLayoutMetrics, + const winrt::Microsoft::ReactNative::LayoutMetrics &oldLayoutMetrics) noexcept { + view.UserData() + .as() + ->UpdateLayoutMetrics(view, newLayoutMetrics, oldLayoutMetrics); + }); + builder.SetUpdateEventEmitterHandler( + [](const winrt::Microsoft::ReactNative::ComponentView &view, + const winrt::Microsoft::ReactNative::EventEmitter &eventEmitter) noexcept { + view.UserData() + .as() + ->UpdateEventEmitter(std::make_shared(eventEmitter)); + }); + builder.SetCustomCommandHandler( + [](const winrt::Microsoft::ReactNative::ComponentView &view, + const winrt::Microsoft::ReactNative::HandleCommandArgs &args) noexcept { + view.UserData().as()->HandleCommand(view, args); + }); + builder.SetMountChildComponentViewHandler( + [](const winrt::Microsoft::ReactNative::ComponentView &view, + const winrt::Microsoft::ReactNative::MountChildComponentViewArgs &args) noexcept { + view.UserData().as()->MountChildComponentView(view, args); + }); + builder.SetUnmountChildComponentViewHandler( + [](const winrt::Microsoft::ReactNative::ComponentView &view, + const winrt::Microsoft::ReactNative::UnmountChildComponentViewArgs &args) noexcept { + view.UserData().as()->UnmountChildComponentView(view, args); + }); + compositionBuilder.SetPortalComponentViewInitializer( + [](const winrt::Microsoft::ReactNative::Composition::PortalComponentView &portalView) noexcept { + auto userData = winrt::make_self(); + userData->InitializePortal(portalView); + portalView.UserData(*userData); + }); + }); +} + +#endif diff --git a/packages/components/Callout/windows/FRNCallout/CalloutComponentView.h b/packages/components/Callout/windows/FRNCallout/CalloutComponentView.h new file mode 100644 index 0000000000..50881eb111 --- /dev/null +++ b/packages/components/Callout/windows/FRNCallout/CalloutComponentView.h @@ -0,0 +1,7 @@ +#pragma once + +#ifdef RNW_NEW_ARCH + +void RegisterCalloutComponentView(winrt::Microsoft::ReactNative::IReactPackageBuilder const &packageBuilder) noexcept; + +#endif diff --git a/packages/components/Callout/windows/FRNCallout/FRNCallout.def b/packages/components/Callout/windows/FRNCallout/FRNCallout.def new file mode 100644 index 0000000000..24e7c1235c --- /dev/null +++ b/packages/components/Callout/windows/FRNCallout/FRNCallout.def @@ -0,0 +1,3 @@ +EXPORTS +DllCanUnloadNow = WINRT_CanUnloadNow PRIVATE +DllGetActivationFactory = WINRT_GetActivationFactory PRIVATE diff --git a/packages/components/Callout/windows/FRNCallout/FRNCallout.rc b/packages/components/Callout/windows/FRNCallout/FRNCallout.rc new file mode 100644 index 0000000000..3212c105fc Binary files /dev/null and b/packages/components/Callout/windows/FRNCallout/FRNCallout.rc differ diff --git a/packages/components/Callout/windows/FRNCallout/FRNCallout.vcxproj b/packages/components/Callout/windows/FRNCallout/FRNCallout.vcxproj new file mode 100644 index 0000000000..e1b8d8e620 --- /dev/null +++ b/packages/components/Callout/windows/FRNCallout/FRNCallout.vcxproj @@ -0,0 +1,146 @@ + + + + + + true + true + {3647410B-5E48-4EFE-A381-631BD3AF192F} + FRNCallout + Win32Proj + FRNCallout + 10.0 + en-US + 17.0 + false + + + + $([MSBuild]::GetDirectoryNameOfFileAbove($(MSBuildThisFileDirectory), 'node_modules\react-native-windows\package.json'))\node_modules\react-native-windows\ + false + true + + + + + Debug + Win32 + + + Release + Win32 + + + Debug + x64 + + + Release + x64 + + + Debug + ARM64 + + + Release + ARM64 + + + + DynamicLibrary + Unicode + v145 + v143 + false + + + true + + + false + true + + + + + + + + + + + + + Use + pch.h + $(IntDir)pch.pch + Level4 + true + %(AdditionalOptions) /bigobj + 4453;28204 + _WINRT_DLL;%(PreprocessorDefinitions) + $(WindowsSDK_WindowsMetadata);$(AdditionalUsingDirectories) + stdcpp20 + + $(MSBuildThisFileDirectory); + %(AdditionalIncludeDirectories) + + + + comctl32.lib;shell32.lib;user32.lib;windowsapp.lib;%(AdditionalDependenices) + Console + true + FRNCallout.def + + + + + _DEBUG;%(PreprocessorDefinitions) + + + + + NDEBUG;%(PreprocessorDefinitions) + + + + + + + ReactPackageProvider.idl + + + + + + + + + Create + + + ReactPackageProvider.idl + + + + + + + + + + + + + + + + + + This project references targets in your node_modules\react-native-windows folder. The missing file is {0}. + + + + + \ No newline at end of file diff --git a/packages/components/Callout/windows/FRNCallout/FRNCallout.vcxproj.filters b/packages/components/Callout/windows/FRNCallout/FRNCallout.vcxproj.filters new file mode 100644 index 0000000000..8d42bfe6aa --- /dev/null +++ b/packages/components/Callout/windows/FRNCallout/FRNCallout.vcxproj.filters @@ -0,0 +1,44 @@ + + + + + {4FC737F1-C7A5-4376-A066-2A32D752A2FF} + cpp;c;cc;cxx;def;odl;idl;hpj;bat;asm;asmx + + + {93995380-89BD-4b04-88EB-625FBE52EBFB} + h;hh;hpp;hxx;hm;inl;inc;ipp;xsd + + + {67DA6AB6-F800-4c08-8B7A-83BB121AAD01} + rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms + + + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + + + Source Files + + + Source Files + + + + + Resource Files + + + \ No newline at end of file diff --git a/packages/components/Callout/windows/FRNCallout/ReactPackageProvider.cpp b/packages/components/Callout/windows/FRNCallout/ReactPackageProvider.cpp new file mode 100644 index 0000000000..ca266ba29d --- /dev/null +++ b/packages/components/Callout/windows/FRNCallout/ReactPackageProvider.cpp @@ -0,0 +1,24 @@ +#include "pch.h" + +#include "ReactPackageProvider.h" +#if __has_include("ReactPackageProvider.g.cpp") +#include "ReactPackageProvider.g.cpp" +#endif + +#include "CalloutComponentView.h" + +using namespace winrt::Microsoft::ReactNative; + +namespace winrt::FRNCallout::implementation +{ + +void ReactPackageProvider::CreatePackage(IReactPackageBuilder const &packageBuilder) noexcept +{ +#ifdef RNW_NEW_ARCH + RegisterCalloutComponentView(packageBuilder); +#else + UNREFERENCED_PARAMETER(packageBuilder); +#endif +} + +} // namespace winrt::FRNCallout::implementation diff --git a/packages/components/Callout/windows/FRNCallout/ReactPackageProvider.h b/packages/components/Callout/windows/FRNCallout/ReactPackageProvider.h new file mode 100644 index 0000000000..74ed90a10d --- /dev/null +++ b/packages/components/Callout/windows/FRNCallout/ReactPackageProvider.h @@ -0,0 +1,24 @@ +#pragma once + +#include "ReactPackageProvider.g.h" + +using namespace winrt::Microsoft::ReactNative; + +namespace winrt::FRNCallout::implementation +{ + +struct ReactPackageProvider : ReactPackageProviderT +{ + ReactPackageProvider() = default; + + void CreatePackage(IReactPackageBuilder const &packageBuilder) noexcept; +}; + +} // namespace winrt::FRNCallout::implementation + +namespace winrt::FRNCallout::factory_implementation +{ + +struct ReactPackageProvider : ReactPackageProviderT {}; + +} // namespace winrt::FRNCallout::factory_implementation diff --git a/packages/components/Callout/windows/FRNCallout/ReactPackageProvider.idl b/packages/components/Callout/windows/FRNCallout/ReactPackageProvider.idl new file mode 100644 index 0000000000..0d07ea44b7 --- /dev/null +++ b/packages/components/Callout/windows/FRNCallout/ReactPackageProvider.idl @@ -0,0 +1,9 @@ +namespace FRNCallout +{ + [webhosthidden] + [default_interface] + runtimeclass ReactPackageProvider : Microsoft.ReactNative.IReactPackageProvider + { + ReactPackageProvider(); + }; +} diff --git a/packages/components/Callout/windows/FRNCallout/codegen/.clang-format b/packages/components/Callout/windows/FRNCallout/codegen/.clang-format new file mode 100644 index 0000000000..a43d914ec3 --- /dev/null +++ b/packages/components/Callout/windows/FRNCallout/codegen/.clang-format @@ -0,0 +1,2 @@ +DisableFormat: true +SortIncludes: false \ No newline at end of file diff --git a/packages/components/Callout/windows/FRNCallout/codegen/react/components/FRNCalloutSpec/RCTCallout.g.h b/packages/components/Callout/windows/FRNCallout/codegen/react/components/FRNCalloutSpec/RCTCallout.g.h new file mode 100644 index 0000000000..5fd80154ce --- /dev/null +++ b/packages/components/Callout/windows/FRNCallout/codegen/react/components/FRNCalloutSpec/RCTCallout.g.h @@ -0,0 +1,375 @@ + +/* + * This file is auto-generated from RCTCalloutNativeComponent spec file in flow / TypeScript. + */ +// clang-format off +#pragma once + +#include + +#ifdef RNW_NEW_ARCH +#include + +#include +#include +#endif // #ifdef RNW_NEW_ARCH + +#ifdef RNW_NEW_ARCH + +namespace FRNCalloutCodegen { + +REACT_STRUCT(RCTCalloutSpec_RCTCalloutProps_anchorRect) +struct RCTCalloutSpec_RCTCalloutProps_anchorRect { + REACT_FIELD(screenX) + double screenX{}; + + REACT_FIELD(screenY) + double screenY{}; + + REACT_FIELD(width) + double width{}; + + REACT_FIELD(height) + double height{}; +}; + +REACT_STRUCT(RCTCalloutProps) +struct RCTCalloutProps : winrt::implements { + RCTCalloutProps(winrt::Microsoft::ReactNative::ViewProps props, const winrt::Microsoft::ReactNative::IComponentProps& cloneFrom) + : ViewProps(props) + { + if (cloneFrom) { + auto cloneFromProps = cloneFrom.as(); + accessibilityLabel = cloneFromProps->accessibilityLabel; + accessibilityOnShowAnnouncement = cloneFromProps->accessibilityOnShowAnnouncement; + anchorRect = cloneFromProps->anchorRect; + beakWidth = cloneFromProps->beakWidth; + dismissBehaviors = cloneFromProps->dismissBehaviors; + doNotTakePointerCapture = cloneFromProps->doNotTakePointerCapture; + focusable = cloneFromProps->focusable; + gapSpace = cloneFromProps->gapSpace; + isBeakVisible = cloneFromProps->isBeakVisible; + maxHeight = cloneFromProps->maxHeight; + maxWidth = cloneFromProps->maxWidth; + minPadding = cloneFromProps->minPadding; + minWidth = cloneFromProps->minWidth; + setInitialFocus = cloneFromProps->setInitialFocus; + testID = cloneFromProps->testID; + directionalHint = cloneFromProps->directionalHint; + target = cloneFromProps->target.Copy(); + onRestoreFocus = cloneFromProps->onRestoreFocus; + onDismiss = cloneFromProps->onDismiss; + onShow = cloneFromProps->onShow; + } + } + + void SetProp(uint32_t hash, winrt::hstring propName, winrt::Microsoft::ReactNative::IJSValueReader value) noexcept { + winrt::Microsoft::ReactNative::ReadProp(hash, propName, value, *this); + } + + REACT_FIELD(accessibilityLabel) + std::optional accessibilityLabel; + + REACT_FIELD(accessibilityOnShowAnnouncement) + std::optional accessibilityOnShowAnnouncement; + + REACT_FIELD(anchorRect) + std::optional anchorRect; + + REACT_FIELD(beakWidth) + std::optional beakWidth{}; + + REACT_FIELD(dismissBehaviors) + std::optional> dismissBehaviors; + + REACT_FIELD(doNotTakePointerCapture) + std::optional doNotTakePointerCapture{}; + + REACT_FIELD(focusable) + std::optional focusable{}; + + REACT_FIELD(gapSpace) + std::optional gapSpace{}; + + REACT_FIELD(isBeakVisible) + std::optional isBeakVisible{}; + + REACT_FIELD(maxHeight) + std::optional maxHeight{}; + + REACT_FIELD(maxWidth) + std::optional maxWidth{}; + + REACT_FIELD(minPadding) + std::optional minPadding{}; + + REACT_FIELD(minWidth) + std::optional minWidth{}; + + REACT_FIELD(setInitialFocus) + std::optional setInitialFocus{}; + + REACT_FIELD(testID) + std::optional testID; + + REACT_FIELD(directionalHint) + std::optional directionalHint; + + REACT_FIELD(target) + winrt::Microsoft::ReactNative::JSValue target{nullptr}; + + // These fields can be used to determine if JS has registered for this event + REACT_FIELD(onRestoreFocus) + bool onRestoreFocus{false}; + + REACT_FIELD(onDismiss) + bool onDismiss{false}; + + REACT_FIELD(onShow) + bool onShow{false}; + + const winrt::Microsoft::ReactNative::ViewProps ViewProps; +}; + +REACT_STRUCT(RCTCalloutSpec_onShow) +struct RCTCalloutSpec_onShow { + REACT_FIELD(target) + int32_t target{}; +}; + +REACT_STRUCT(RCTCalloutSpec_onDismiss) +struct RCTCalloutSpec_onDismiss { + REACT_FIELD(target) + int32_t target{}; +}; + +REACT_STRUCT(RCTCalloutSpec_onRestoreFocus) +struct RCTCalloutSpec_onRestoreFocus { + REACT_FIELD(target) + int32_t target{}; + + REACT_FIELD(containsFocus) + bool containsFocus{}; +}; + +struct RCTCalloutEventEmitter { + RCTCalloutEventEmitter(const winrt::Microsoft::ReactNative::EventEmitter &eventEmitter) + : m_eventEmitter(eventEmitter) {} + + using OnRestoreFocus = RCTCalloutSpec_onRestoreFocus; + using OnDismiss = RCTCalloutSpec_onDismiss; + using OnShow = RCTCalloutSpec_onShow; + + void onRestoreFocus(OnRestoreFocus &&value) const { + m_eventEmitter.DispatchEvent(L"restoreFocus", [value = std::move(value)](const winrt::Microsoft::ReactNative::IJSValueWriter writer) { + winrt::Microsoft::ReactNative::WriteValue(writer, value); + }); + } + + void onDismiss(OnDismiss &&value) const { + m_eventEmitter.DispatchEvent(L"dismiss", [value = std::move(value)](const winrt::Microsoft::ReactNative::IJSValueWriter writer) { + winrt::Microsoft::ReactNative::WriteValue(writer, value); + }); + } + + void onShow(OnShow &&value) const { + m_eventEmitter.DispatchEvent(L"show", [value = std::move(value)](const winrt::Microsoft::ReactNative::IJSValueWriter writer) { + winrt::Microsoft::ReactNative::WriteValue(writer, value); + }); + } + + private: + winrt::Microsoft::ReactNative::EventEmitter m_eventEmitter{nullptr}; +}; + +template +struct BaseRCTCallout { + + virtual void UpdateProps( + const winrt::Microsoft::ReactNative::ComponentView &/*view*/, + const winrt::com_ptr &newProps, + const winrt::com_ptr &/*oldProps*/) noexcept { + m_props = newProps; + } + + // UpdateLayoutMetrics will only be called if this method is overridden + virtual void UpdateLayoutMetrics( + const winrt::Microsoft::ReactNative::ComponentView &/*view*/, + const winrt::Microsoft::ReactNative::LayoutMetrics &/*newLayoutMetrics*/, + const winrt::Microsoft::ReactNative::LayoutMetrics &/*oldLayoutMetrics*/) noexcept { + } + + // UpdateState will only be called if this method is overridden + virtual void UpdateState( + const winrt::Microsoft::ReactNative::ComponentView &/*view*/, + const winrt::Microsoft::ReactNative::IComponentState &/*newState*/) noexcept { + } + + virtual void UpdateEventEmitter(const std::shared_ptr &eventEmitter) noexcept { + m_eventEmitter = eventEmitter; + } + + // MountChildComponentView will only be called if this method is overridden + virtual void MountChildComponentView(const winrt::Microsoft::ReactNative::ComponentView &/*view*/, + const winrt::Microsoft::ReactNative::MountChildComponentViewArgs &/*args*/) noexcept { + } + + // UnmountChildComponentView will only be called if this method is overridden + virtual void UnmountChildComponentView(const winrt::Microsoft::ReactNative::ComponentView &/*view*/, + const winrt::Microsoft::ReactNative::UnmountChildComponentViewArgs &/*args*/) noexcept { + } + + // Initialize will only be called if this method is overridden + virtual void Initialize(const winrt::Microsoft::ReactNative::ComponentView &/*view*/) noexcept { + } + + // CreateVisual will only be called if this method is overridden + virtual winrt::Microsoft::UI::Composition::Visual CreateVisual(const winrt::Microsoft::ReactNative::ComponentView &view) noexcept { + return view.as().Compositor().CreateSpriteVisual(); + } + + // FinalizeUpdate will only be called if this method is overridden + virtual void FinalizeUpdate(const winrt::Microsoft::ReactNative::ComponentView &/*view*/, + winrt::Microsoft::ReactNative::ComponentViewUpdateMask /*mask*/) noexcept { + } + + // CreateAutomationPeer will only be called if this method is overridden + virtual winrt::Windows::Foundation::IInspectable CreateAutomationPeer(const winrt::Microsoft::ReactNative::ComponentView & /*view*/, + const winrt::Microsoft::ReactNative::CreateAutomationPeerArgs& /*args*/) noexcept { + return nullptr; + } + + // You must provide an implementation of this method to handle the "focusWindow" command + virtual void HandleFocusWindowCommand() noexcept = 0; + + // You must provide an implementation of this method to handle the "blurWindow" command + virtual void HandleBlurWindowCommand() noexcept = 0; + + void HandleCommand(const winrt::Microsoft::ReactNative::ComponentView &view, const winrt::Microsoft::ReactNative::HandleCommandArgs& args) noexcept { + auto userData = view.UserData().as(); + auto commandName = args.CommandName(); + if (commandName == L"focusWindow") { + + userData->HandleFocusWindowCommand(); + return; + } + + if (commandName == L"blurWindow") { + + userData->HandleBlurWindowCommand(); + return; + } + } + + const std::shared_ptr& EventEmitter() const { return m_eventEmitter; } + const winrt::com_ptr& Props() const { return m_props; } + +private: + winrt::com_ptr m_props; + std::shared_ptr m_eventEmitter; +}; + +template +void RegisterRCTCalloutNativeComponent( + winrt::Microsoft::ReactNative::IReactPackageBuilder const &packageBuilder, + std::function builderCallback) noexcept { + packageBuilder.as().AddViewComponent( + L"RCTCallout", [builderCallback](winrt::Microsoft::ReactNative::IReactViewComponentBuilder const &builder) noexcept { + auto compBuilder = builder.as(); + + builder.SetCreateProps([](winrt::Microsoft::ReactNative::ViewProps props, + const winrt::Microsoft::ReactNative::IComponentProps& cloneFrom) noexcept { + return winrt::make(props, cloneFrom); + }); + + builder.SetUpdatePropsHandler([](const winrt::Microsoft::ReactNative::ComponentView &view, + const winrt::Microsoft::ReactNative::IComponentProps &newProps, + const winrt::Microsoft::ReactNative::IComponentProps &oldProps) noexcept { + auto userData = view.UserData().as(); + userData->UpdateProps(view, newProps ? newProps.as() : nullptr, oldProps ? oldProps.as() : nullptr); + }); + + compBuilder.SetUpdateLayoutMetricsHandler([](const winrt::Microsoft::ReactNative::ComponentView &view, + const winrt::Microsoft::ReactNative::LayoutMetrics &newLayoutMetrics, + const winrt::Microsoft::ReactNative::LayoutMetrics &oldLayoutMetrics) noexcept { + auto userData = view.UserData().as(); + userData->UpdateLayoutMetrics(view, newLayoutMetrics, oldLayoutMetrics); + }); + + builder.SetUpdateEventEmitterHandler([](const winrt::Microsoft::ReactNative::ComponentView &view, + const winrt::Microsoft::ReactNative::EventEmitter &eventEmitter) noexcept { + auto userData = view.UserData().as(); + userData->UpdateEventEmitter(std::make_shared(eventEmitter)); + }); + + if CONSTEXPR_SUPPORTED_ON_VIRTUAL_FN_ADDRESS (&TUserData::FinalizeUpdate != &BaseRCTCallout::FinalizeUpdate) { + builder.SetFinalizeUpdateHandler([](const winrt::Microsoft::ReactNative::ComponentView &view, + winrt::Microsoft::ReactNative::ComponentViewUpdateMask mask) noexcept { + auto userData = view.UserData().as(); + userData->FinalizeUpdate(view, mask); + }); + } + + if CONSTEXPR_SUPPORTED_ON_VIRTUAL_FN_ADDRESS (&TUserData::UpdateState != &BaseRCTCallout::UpdateState) { + builder.SetUpdateStateHandler([](const winrt::Microsoft::ReactNative::ComponentView &view, + const winrt::Microsoft::ReactNative::IComponentState &newState) noexcept { + auto userData = view.UserData().as(); + userData->UpdateState(view, newState); + }); + } + + builder.SetCustomCommandHandler([](const winrt::Microsoft::ReactNative::ComponentView &view, + const winrt::Microsoft::ReactNative::HandleCommandArgs& args) noexcept { + auto userData = view.UserData().as(); + userData->HandleCommand(view, args); + }); + + if CONSTEXPR_SUPPORTED_ON_VIRTUAL_FN_ADDRESS (&TUserData::MountChildComponentView != &BaseRCTCallout::MountChildComponentView) { + builder.SetMountChildComponentViewHandler([](const winrt::Microsoft::ReactNative::ComponentView &view, + const winrt::Microsoft::ReactNative::MountChildComponentViewArgs &args) noexcept { + auto userData = view.UserData().as(); + return userData->MountChildComponentView(view, args); + }); + } + + if CONSTEXPR_SUPPORTED_ON_VIRTUAL_FN_ADDRESS (&TUserData::UnmountChildComponentView != &BaseRCTCallout::UnmountChildComponentView) { + builder.SetUnmountChildComponentViewHandler([](const winrt::Microsoft::ReactNative::ComponentView &view, + const winrt::Microsoft::ReactNative::UnmountChildComponentViewArgs &args) noexcept { + auto userData = view.UserData().as(); + return userData->UnmountChildComponentView(view, args); + }); + } + + if CONSTEXPR_SUPPORTED_ON_VIRTUAL_FN_ADDRESS (&TUserData::CreateAutomationPeer != &BaseRCTCallout::CreateAutomationPeer) { + builder.SetCreateAutomationPeerHandler([](const winrt::Microsoft::ReactNative::ComponentView &view, + const winrt::Microsoft::ReactNative::CreateAutomationPeerArgs& args) noexcept { + auto userData = view.UserData().as(); + return userData->CreateAutomationPeer(view, args); + }); + } + + compBuilder.SetViewComponentViewInitializer([](const winrt::Microsoft::ReactNative::ComponentView &view) noexcept { + auto userData = winrt::make_self(); + if CONSTEXPR_SUPPORTED_ON_VIRTUAL_FN_ADDRESS (&TUserData::Initialize != &BaseRCTCallout::Initialize) { + userData->Initialize(view); + } + view.UserData(*userData); + }); + + if CONSTEXPR_SUPPORTED_ON_VIRTUAL_FN_ADDRESS (&TUserData::CreateVisual != &BaseRCTCallout::CreateVisual) { + compBuilder.SetCreateVisualHandler([](const winrt::Microsoft::ReactNative::ComponentView &view) noexcept { + auto userData = view.UserData().as(); + return userData->CreateVisual(view); + }); + } + + // Allow app to further customize the builder + if (builderCallback) { + builderCallback(compBuilder); + } + }); +} + +} // namespace FRNCalloutCodegen + +#endif // #ifdef RNW_NEW_ARCH diff --git a/packages/components/Callout/windows/FRNCallout/packages.lock.json b/packages/components/Callout/windows/FRNCallout/packages.lock.json new file mode 100644 index 0000000000..17e459d260 --- /dev/null +++ b/packages/components/Callout/windows/FRNCallout/packages.lock.json @@ -0,0 +1,151 @@ +{ + "version": 1, + "dependencies": { + "native,Version=v0.0": { + "boost": { + "type": "Direct", + "requested": "[1.84.0, )", + "resolved": "1.84.0", + "contentHash": "4el2YP3cNJDVFPdzOso+LxGvdWP2rHxML4siq8VdonNypW2m4q503tHfCj6vK0L1UfxioE2hpFGb4ITEua73tg==" + }, + "Microsoft.ReactNative": { + "type": "Direct", + "requested": "[0.81.32, )", + "resolved": "0.81.32", + "contentHash": "Ks1M8X7hk/yFF0LV4PymQelr1A2PqselgqBIuJIvJgI0bnO0OMXDXqj6C1SDJCAaKUa5U3/G78h+yUPa4EKsJQ==" + }, + "Microsoft.ReactNative.Cxx": { + "type": "Direct", + "requested": "[0.81.32, )", + "resolved": "0.81.32", + "contentHash": "7sk1LmAne4vDt5BWUg2drRw85ktACDdBLBMGK2y0iXWwZjayhmzkMBmVhX4zoN6t+bYu/Z2/LfyOVqQJCok7Sg==", + "dependencies": { + "Microsoft.ReactNative": "0.81.32" + } + }, + "Microsoft.VCRTForwarders.140": { + "type": "Direct", + "requested": "[1.0.2-rc, )", + "resolved": "1.0.2-rc", + "contentHash": "/r+sjtEeCIGyDhobIZ5hSmYhC/dSyGZxf1SxYJpElUhB0LMCktOMFs9gXrauXypIFECpVynNyVjAmJt6hjJ5oQ==" + }, + "Microsoft.Windows.CppWinRT": { + "type": "Direct", + "requested": "[2.0.230706.1, )", + "resolved": "2.0.230706.1", + "contentHash": "l0D7oCw/5X+xIKHqZTi62TtV+1qeSz7KVluNFdrJ9hXsst4ghvqQ/Yhura7JqRdZWBXAuDS0G0KwALptdoxweQ==" + }, + "Microsoft.WindowsAppSDK": { + "type": "Direct", + "requested": "[1.8.260508005, )", + "resolved": "1.8.260508005", + "contentHash": "+aA+zrvqJKgsn/1TPOSR0Uy7dkMO5jI/+cDWPu2pWmXe4KQxYkR8gQFY9IrfbgCxxSd/yl1zMcAKD/4HBbNqaw==", + "dependencies": { + "Microsoft.WindowsAppSDK.AI": "[1.8.76]", + "Microsoft.WindowsAppSDK.Base": "[1.8.251216001]", + "Microsoft.WindowsAppSDK.DWrite": "[1.8.25122902]", + "Microsoft.WindowsAppSDK.Foundation": "[1.8.260505001]", + "Microsoft.WindowsAppSDK.InteractiveExperiences": "[1.8.260430001]", + "Microsoft.WindowsAppSDK.ML": "[1.8.2197]", + "Microsoft.WindowsAppSDK.Runtime": "[1.8.260508005]", + "Microsoft.WindowsAppSDK.Widgets": "[1.8.251231004]", + "Microsoft.WindowsAppSDK.WinUI": "[1.8.260505002]" + } + }, + "Microsoft.Web.WebView2": { + "type": "Transitive", + "resolved": "1.0.3179.45", + "contentHash": "3pokSH5CnN0G6rGhGFo1y87inxYhNxBQ2Vdf0wlvBj99KHxQJormjDACmqRnFeUsmuNFIhWwfAL1ztq7wD5qRA==" + }, + "Microsoft.Windows.SDK.BuildTools": { + "type": "Transitive", + "resolved": "10.0.26100.4654", + "contentHash": "2mgcOlj/t2RfSyyw+pVESfO+Tk1RkfQzto9Vrq42M1lUQIfQEwbi8QLha9GXWIOj+TFzeHIEJckIoF25mgiM8A==" + }, + "Microsoft.Windows.SDK.BuildTools.MSIX": { + "type": "Transitive", + "resolved": "1.7.20250829.1", + "contentHash": "IMdvRmCIZnBS5GkYnv0po1bcx6U1OF39pqA4TphQ9evDzpCRoSE19/PkDvlUNNrBavTsLIEJgd/TAIFner75ow==" + }, + "Microsoft.WindowsAppSDK.AI": { + "type": "Transitive", + "resolved": "1.8.76", + "contentHash": "Ayn9QybcwzH+c8eQlE7dm2oO3Jrcn2uohLcsHJpCbLOAfcisjzwtSBe0oyulbaJ86R4eDSX3RDS25tsjGpIqyQ==", + "dependencies": { + "Microsoft.WindowsAppSDK.Base": "1.8.251216001", + "Microsoft.WindowsAppSDK.Foundation": "1.8.260501000" + } + }, + "Microsoft.WindowsAppSDK.Base": { + "type": "Transitive", + "resolved": "1.8.251216001", + "contentHash": "PS1wriuFknz3W2F2P/e6RvOTM35w89Lsj/f0QmUEPrJjKnc+jM0JLX1vfdytI14y1gNRUTm9uclwP0aH/SVU5w==", + "dependencies": { + "Microsoft.Windows.SDK.BuildTools": "10.0.26100.4654", + "Microsoft.Windows.SDK.BuildTools.MSIX": "1.7.20250829.1" + } + }, + "Microsoft.WindowsAppSDK.DWrite": { + "type": "Transitive", + "resolved": "1.8.25122902", + "contentHash": "zFNn07i7Cyz62Y8FnPQAyzeZK7ww3m9t42i9pzy4C04pNbyUDQ4fG7pB6VSh6n4EyFuYtuFQuDzt4mKmXFrkrg==", + "dependencies": { + "Microsoft.WindowsAppSDK.Base": "1.8.251216001" + } + }, + "Microsoft.WindowsAppSDK.Foundation": { + "type": "Transitive", + "resolved": "1.8.260505001", + "contentHash": "41SSoEn3sKKCAVPA/w18zVJwV1C7aDEkPS2f6Zyp19m27tDEmteEp0XS3Ln3b0ElYR4FfPEPvIDbJPSA9vePGw==", + "dependencies": { + "Microsoft.WindowsAppSDK.Base": "1.8.251216001", + "Microsoft.WindowsAppSDK.InteractiveExperiences": "1.8.260430001" + } + }, + "Microsoft.WindowsAppSDK.InteractiveExperiences": { + "type": "Transitive", + "resolved": "1.8.260430001", + "contentHash": "fTPCnQb3ZarMh9khlEfbLDllcZzK0tSP+2S6W/T4cPyLLSAmARm8Gd3AC5kqubnDjzTvG+1lNZ1bZGFsIHplJQ==", + "dependencies": { + "Microsoft.WindowsAppSDK.Base": "1.8.251216001" + } + }, + "Microsoft.WindowsAppSDK.ML": { + "type": "Transitive", + "resolved": "1.8.2197", + "contentHash": "6Bc1SOLd5HicY3GbF+zr76YBfH4iZOKeEGxTK/lHKAK2ExZTWavOADxB2CKSi/irF4dWSngUdRFopWPmckJ6fA==", + "dependencies": { + "Microsoft.WindowsAppSDK.Base": "1.8.251216001", + "Microsoft.WindowsAppSDK.Foundation": "1.8.260505001" + } + }, + "Microsoft.WindowsAppSDK.Runtime": { + "type": "Transitive", + "resolved": "1.8.260508005", + "contentHash": "2JqXzA4heHSkkaXJNyEvYtpv2wsEUMhwQzgXmZsFmYSkUXSdeVc6hdHcWoBK1SY9l1Sjd1brLt7h16kIpgh2kA==", + "dependencies": { + "Microsoft.WindowsAppSDK.Base": "1.8.251216001" + } + }, + "Microsoft.WindowsAppSDK.Widgets": { + "type": "Transitive", + "resolved": "1.8.251231004", + "contentHash": "bIWqQYR8DCoB1SoPOMil5AtgtkTn438wJTdpsHgyO/6o7Eh7PMP5BzrR0KbDsFqy+4LhPWQ4vtwko5k93fECcA==", + "dependencies": { + "Microsoft.WindowsAppSDK.Base": "1.8.251216001" + } + }, + "Microsoft.WindowsAppSDK.WinUI": { + "type": "Transitive", + "resolved": "1.8.260505002", + "contentHash": "/hGl6EOmo8aeJR2bYbOmGhOicnJK4EY+u+cQI+jm8H1uXThfMX32DsfKOt9LkOcu8AKX3j5FpiBay8ObUSytIw==", + "dependencies": { + "Microsoft.Web.WebView2": "1.0.3179.45", + "Microsoft.WindowsAppSDK.Base": "1.8.251216001", + "Microsoft.WindowsAppSDK.Foundation": "1.8.260505001", + "Microsoft.WindowsAppSDK.InteractiveExperiences": "1.8.260430001" + } + } + } + } +} diff --git a/packages/components/Callout/windows/FRNCallout/pch.cpp b/packages/components/Callout/windows/FRNCallout/pch.cpp new file mode 100644 index 0000000000..1d9f38c57d --- /dev/null +++ b/packages/components/Callout/windows/FRNCallout/pch.cpp @@ -0,0 +1 @@ +#include "pch.h" diff --git a/packages/components/Callout/windows/FRNCallout/pch.h b/packages/components/Callout/windows/FRNCallout/pch.h new file mode 100644 index 0000000000..19f7ce75ae --- /dev/null +++ b/packages/components/Callout/windows/FRNCallout/pch.h @@ -0,0 +1,36 @@ +// pch.h : include file for standard system include files, +// or project specific include files that are used frequently, but +// are changed infrequently +// + +#pragma once + +#include "targetver.h" + +#define NOMINMAX 1 +#define WIN32_LEAN_AND_MEAN 1 +#define WINRT_LEAN_AND_MEAN 1 + +// Windows Header Files +#include +#undef GetCurrentTime +#include + +// WinRT Header Files +#include +#include +#include +#include + +// C RunTime Header Files +#include +#include +#include +#include + +#include +#include +#include +#include + +// Reference additional headers your project requires here diff --git a/packages/components/Callout/windows/FRNCallout/resource.h b/packages/components/Callout/windows/FRNCallout/resource.h new file mode 100644 index 0000000000..49e12db2bc --- /dev/null +++ b/packages/components/Callout/windows/FRNCallout/resource.h @@ -0,0 +1,5 @@ +// +// Microsoft Visual C++ generated include file. +// Used by FRNCallout.rc + +#pragma once diff --git a/packages/components/Callout/windows/FRNCallout/targetver.h b/packages/components/Callout/windows/FRNCallout/targetver.h new file mode 100644 index 0000000000..87c0086de7 --- /dev/null +++ b/packages/components/Callout/windows/FRNCallout/targetver.h @@ -0,0 +1,8 @@ +#pragma once + +// Including SDKDDKVer.h defines the highest available Windows platform. + +// If you wish to build your application for a previous Windows platform, include WinSDKVer.h and +// set the _WIN32_WINNT macro to the platform you wish to support before including SDKDDKVer.h. + +#include diff --git a/packages/components/ContextualMenu/src/ContextualMenu.settings.macos.ts b/packages/components/ContextualMenu/src/ContextualMenu.settings.macos.ts index 9b474c0786..16992a0a5b 100644 --- a/packages/components/ContextualMenu/src/ContextualMenu.settings.macos.ts +++ b/packages/components/ContextualMenu/src/ContextualMenu.settings.macos.ts @@ -8,7 +8,7 @@ import { contextualMenuName } from './ContextualMenu.types'; export const settings: IComposeSettings = [ { tokens: { - directionalHint: I18nManager.isRTL ? 'bottomRightEdge' : 'bottonLeftEdge', + directionalHint: I18nManager.isRTL ? 'bottomRightEdge' : 'bottomLeftEdge', }, container: { style: { diff --git a/packages/components/ContextualMenu/src/ContextualMenu.settings.ts b/packages/components/ContextualMenu/src/ContextualMenu.settings.ts index 6c88a1f22d..e1f21bdbd8 100644 --- a/packages/components/ContextualMenu/src/ContextualMenu.settings.ts +++ b/packages/components/ContextualMenu/src/ContextualMenu.settings.ts @@ -10,7 +10,7 @@ export const settings: IComposeSettings = [ beakWidth: 20, borderColor: 'buttonBorder', borderWidth: 1, - directionalHint: 'bottonLeftEdge', + directionalHint: 'bottomLeftEdge', gapSpace: 0, minPadding: 0, }, diff --git a/yarn.lock b/yarn.lock index 46fd9dd364..820ec35c4d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2591,6 +2591,7 @@ __metadata: resolution: "@fluentui-react-native/agentic-components-storybook@workspace:packages/agentic-components/storybook" dependencies: "@babel/core": "catalog:" + "@fluentui-react-native/callout": "workspace:*" "@fluentui-react-native/components": "workspace:*" "@fluentui-react-native/default-theme": "workspace:*" "@fluentui-react-native/design": "workspace:*" @@ -2598,8 +2599,6 @@ __metadata: "@react-native-community/cli": "npm:^20.0.0" "@react-native-community/cli-platform-android": "npm:^20.0.0" "@react-native-community/cli-platform-ios": "npm:^20.0.0" - "@react-native-community/datetimepicker": "npm:^9.1.0" - "@react-native-community/slider": "npm:^5.0.0" "@react-native-windows/cli": "npm:^0.81.0" "@react-native/babel-preset": "npm:^0.81.0" "@react-native/metro-babel-transformer": "npm:^0.81.0" @@ -8483,25 +8482,6 @@ __metadata: languageName: node linkType: hard -"@react-native-community/datetimepicker@npm:^9.1.0": - version: 9.1.0 - resolution: "@react-native-community/datetimepicker@npm:9.1.0" - dependencies: - invariant: "npm:^2.2.4" - peerDependencies: - expo: ">=52.0.0" - react: "*" - react-native: "*" - react-native-windows: "*" - peerDependenciesMeta: - expo: - optional: true - react-native-windows: - optional: true - checksum: 10c0/3f31baec58c1245204a9b4057809027019aa1abb4b8caea4cb4d44ab70f5a97dfe5114a8aa315299aa47ad31971059181c355aa056d9c3a14b7892582da9f4cb - languageName: node - linkType: hard - "@react-native-community/slider@npm:^4.5.7": version: 4.5.7 resolution: "@react-native-community/slider@npm:4.5.7" @@ -8509,13 +8489,6 @@ __metadata: languageName: node linkType: hard -"@react-native-community/slider@npm:^5.0.0": - version: 5.2.0 - resolution: "@react-native-community/slider@npm:5.2.0" - checksum: 10c0/58ec6a47b5aafefd98a61c79aeae05f1ab8371782eb497d9af665bf2f62a1c372da8fed6dead30e1d6c9e01bfb12bfdbafcde7890736f4f5d55502dabf2e01ad - languageName: node - linkType: hard - "@react-native-macos/virtualized-lists@npm:0.81.9": version: 0.81.9 resolution: "@react-native-macos/virtualized-lists@npm:0.81.9"