diff --git a/.changeset/calm-callouts-align.md b/.changeset/calm-callouts-align.md new file mode 100644 index 00000000000..c47fe5f29f8 --- /dev/null +++ b/.changeset/calm-callouts-align.md @@ -0,0 +1,5 @@ +--- +"@fluentui-react-native/callout": patch +--- + +Fix macOS placement directions and anchor Windows Callouts to the requested target edge. diff --git a/.github/skills/agentic-component-authoring/references/tests-and-stories.md b/.github/skills/agentic-component-authoring/references/tests-and-stories.md index d259f4a8f0c..4175a237dd2 100644 --- a/.github/skills/agentic-component-authoring/references/tests-and-stories.md +++ b/.github/skills/agentic-component-authoring/references/tests-and-stories.md @@ -94,7 +94,9 @@ resizes or re-roles the component instead of demonstrating a value. Each story module should provide: -- typed `Meta` with `component` and `Components/` or `Primitives/` title +- typed `Meta` with `component` and a title matching the component location: + `Components/` for higher-order components, `Primitives/` for agentic primitives, or `Native/` for + standalone native component packages - useful common args - controls for finite or numeric public props - a short component description diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 7f769475815..3ce36ba8f5a 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -406,10 +406,6 @@ jobs: run: yarn e2etest:win32 working-directory: apps/E2E - - name: Run Storybook Win32 smoke tests - run: yarn win32:ci - working-directory: apps/storybook - - name: Upload E2E Win32 artifacts if: ${{ always() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/apps/storybook/scripts/smoke-stories.json b/apps/storybook/scripts/smoke-stories.json index 1277ba76000..44f44f4b4ee 100644 --- a/apps/storybook/scripts/smoke-stories.json +++ b/apps/storybook/scripts/smoke-stories.json @@ -10,7 +10,7 @@ "artifactName": "icon-default" }, { - "storyId": "primitives-callout--default", + "storyId": "native-callout--default", "testId": "agentic-storybook-callout-trigger", "statusTestId": "agentic-storybook-callout-status", "artifactName": "callout-default" diff --git a/apps/storybook/windows-tests/storybook-smoke.test.cjs b/apps/storybook/windows-tests/storybook-smoke.test.cjs index 5d273074844..7b382574552 100644 --- a/apps/storybook/windows-tests/storybook-smoke.test.cjs +++ b/apps/storybook/windows-tests/storybook-smoke.test.cjs @@ -48,6 +48,46 @@ test('moves focus between Button Overview controls after a click', async () => { expect(await secondary.getAttribute('HasKeyboardFocus')).toBe('True'); }); +const calloutPlacements = [ + { + hint: 'topCenter', + isCorrect: (trigger, callout) => callout.y + callout.height <= trigger.y, + }, + { + hint: 'rightCenter', + isCorrect: (trigger, callout) => callout.x >= trigger.x + trigger.width, + }, + { + hint: 'bottomCenter', + isCorrect: (trigger, callout) => callout.y >= trigger.y + trigger.height, + }, + { + hint: 'leftCenter', + isCorrect: (trigger, callout) => callout.x + callout.width <= trigger.x, + }, +]; + +test.each(calloutPlacements)('anchors the Callout in the $hint direction', async ({ hint, isCorrect }) => { + await selectStory('components-button--default'); + await selectStory('native-callout--placement'); + + const trigger = await app.findElementByTestID(`agentic-storybook-callout-placement-${hint}-trigger`); + await trigger.waitForDisplayed({ timeout: 30000 }); + await trigger.click(); + + const callout = await app.findElementByTestID('agentic-storybook-callout-placement-content'); + await callout.waitForDisplayed({ timeout: 30000 }); + + const [triggerLocation, triggerSize, calloutLocation, calloutSize] = await Promise.all([ + trigger.getLocation(), + trigger.getSize(), + callout.getLocation(), + callout.getSize(), + ]); + + expect(isCorrect({ ...triggerLocation, ...triggerSize }, { ...calloutLocation, ...calloutSize })).toBe(true); +}); + test.each([ ['components-tag--default', 'agentic-storybook-tag'], ['components-accordion--default', 'accordion-header'], diff --git a/packages/native/Callout/macos/FRNCalloutManager.m b/packages/native/Callout/macos/FRNCalloutManager.m index 16eaf75a55d..ca5ebd4b58f 100644 --- a/packages/native/Callout/macos/FRNCalloutManager.m +++ b/packages/native/Callout/macos/FRNCalloutManager.m @@ -16,22 +16,23 @@ + (NSRect)screenRect:(id)json return NSMakeRect(x, y, width, height); } -// Collapse the directional hint options to the 4 NSRectEdge options +// Collapse the directional hint options to the 4 NSRectEdge options. +// CalloutView uses MinY for placement above the anchor and MaxY for placement below it. RCT_ENUM_CONVERTER(NSRectEdge, (@{ @"leftTopEdge": @(NSRectEdgeMinX), @"leftCenter": @(NSRectEdgeMinX), @"leftBottomEdge": @(NSRectEdgeMinX), - @"topLeftEdge": @(NSRectEdgeMaxY), - @"topAutoEdge": @(NSRectEdgeMaxY), - @"topCenter": @(NSRectEdgeMaxY), - @"topRightEdge": @(NSRectEdgeMaxY), + @"topLeftEdge": @(NSRectEdgeMinY), + @"topAutoEdge": @(NSRectEdgeMinY), + @"topCenter": @(NSRectEdgeMinY), + @"topRightEdge": @(NSRectEdgeMinY), @"rightTopEdge": @(NSRectEdgeMaxX), @"rightCenter": @(NSRectEdgeMaxX), @"rightBottomEdge": @(NSRectEdgeMaxX), - @"bottomLeftEdge": @(NSRectEdgeMinY), - @"bottomAutoEdge": @(NSRectEdgeMinY), - @"bottomCenter": @(NSRectEdgeMinY), - @"bottomRightEdge": @(NSRectEdgeMinY), + @"bottomLeftEdge": @(NSRectEdgeMaxY), + @"bottomAutoEdge": @(NSRectEdgeMaxY), + @"bottomCenter": @(NSRectEdgeMaxY), + @"bottomRightEdge": @(NSRectEdgeMaxY), }), NSRectEdgeMaxY, integerValue); @end diff --git a/packages/native/Callout/macos/RCTCalloutComponentView.mm b/packages/native/Callout/macos/RCTCalloutComponentView.mm index 903b7644d8e..86a6d5a259e 100644 --- a/packages/native/Callout/macos/RCTCalloutComponentView.mm +++ b/packages/native/Callout/macos/RCTCalloutComponentView.mm @@ -19,6 +19,7 @@ static NSRectEdge RCTNSRectEdgeFromDirectionalHint(CalloutDirectionalHint hint) { + // CalloutView uses MinY for placement above the anchor and MaxY for placement below it. switch (hint) { case CalloutDirectionalHint::LeftTopEdge: case CalloutDirectionalHint::LeftCenter: @@ -28,7 +29,7 @@ static NSRectEdge RCTNSRectEdgeFromDirectionalHint(CalloutDirectionalHint hint) case CalloutDirectionalHint::TopAutoEdge: case CalloutDirectionalHint::TopCenter: case CalloutDirectionalHint::TopRightEdge: - return NSRectEdgeMaxY; + return NSRectEdgeMinY; case CalloutDirectionalHint::RightTopEdge: case CalloutDirectionalHint::RightCenter: case CalloutDirectionalHint::RightBottomEdge: @@ -37,7 +38,7 @@ static NSRectEdge RCTNSRectEdgeFromDirectionalHint(CalloutDirectionalHint hint) case CalloutDirectionalHint::BottomAutoEdge: case CalloutDirectionalHint::BottomCenter: case CalloutDirectionalHint::BottomRightEdge: - return NSRectEdgeMinY; + return NSRectEdgeMaxY; } } diff --git a/packages/native/Callout/src/Callout.stories.tsx b/packages/native/Callout/src/Callout.stories.tsx index 82e74d58adb..6ae0691aafc 100644 --- a/packages/native/Callout/src/Callout.stories.tsx +++ b/packages/native/Callout/src/Callout.stories.tsx @@ -116,6 +116,7 @@ const PlacementExample = (props: CalloutProps) => { onPress={() => setSelectedIndex(index)} ref={targetRefs[index]} style={({ pressed }) => [styles.trigger, pressed && styles.triggerPressed]} + testID={`agentic-storybook-callout-placement-${hint}-trigger`} > {label} @@ -128,7 +129,7 @@ const PlacementExample = (props: CalloutProps) => { onDismiss={() => setSelectedIndex(undefined)} target={targetRefs[selectedIndex]} > - + {placements[selectedIndex].label} Click outside the native window to dismiss it. @@ -139,7 +140,7 @@ const PlacementExample = (props: CalloutProps) => { }; const meta: Meta = { - title: 'Primitives/Callout', + title: 'Native/Callout', component: Callout, args: { accessibilityLabel: 'Callout example', @@ -213,6 +214,10 @@ const styles = StyleSheet.create({ marginTop: 6, }, calloutContent: { + backgroundColor: '#ffffff', + borderColor: '#d1d1d1', + borderRadius: 8, + borderWidth: 1, padding: 16, width: 280, }, diff --git a/packages/native/Callout/windows/Callout/Callout.cpp b/packages/native/Callout/windows/Callout/Callout.cpp index f6c38f814a9..5ac6311e78c 100644 --- a/packages/native/Callout/windows/Callout/Callout.cpp +++ b/packages/native/Callout/windows/Callout/Callout.cpp @@ -297,44 +297,66 @@ struct CalloutComponentView CompositionUIService::ComponentFromReactTag( m_reactContext.Handle(), Props()->target.AsInt64()); auto targetPos = ViewToScreenOffset(targetView); + auto targetScaleFactor = + targetView.LayoutMetrics().PointScaleFactor; + auto targetWidthPx = static_cast( + targetView.LayoutMetrics().Frame.Width * targetScaleFactor); + auto targetHeightPx = static_cast( + targetView.LayoutMetrics().Frame.Height * targetScaleFactor); + auto targetRight = targetPos.X + targetWidthPx; + auto targetBottom = targetPos.Y + targetHeightPx; + auto targetCenterX = targetPos.X + targetWidthPx / 2; + auto targetCenterY = targetPos.Y + targetHeightPx / 2; POINT anchorPoint{targetPos.X, targetPos.Y}; SIZE windowSize{clientWidthPx, clientHeightPx}; - RECT excludeRect{targetPos.X, targetPos.Y, - targetPos.X + targetView.LayoutMetrics().Frame.Width, - targetPos.Y + targetView.LayoutMetrics().Frame.Height}; + RECT excludeRect{targetPos.X, targetPos.Y, targetRight, targetBottom}; UINT flags = 0; if (m_directionalHint == DirectionalHint::LeftTopEdge) { - flags = TPM_LEFTALIGN | TPM_TOPALIGN | TPM_HORIZONTAL; + anchorPoint = {targetPos.X, targetPos.Y}; + flags = TPM_RIGHTALIGN | TPM_TOPALIGN | TPM_HORIZONTAL; } else if (m_directionalHint == DirectionalHint::LeftCenter) { - flags = TPM_LEFTALIGN | TPM_VCENTERALIGN | TPM_HORIZONTAL; + anchorPoint = {targetPos.X, targetCenterY}; + flags = TPM_RIGHTALIGN | TPM_VCENTERALIGN | TPM_HORIZONTAL; } else if (m_directionalHint == DirectionalHint::LeftBottomEdge) { - flags = TPM_LEFTALIGN | TPM_BOTTOMALIGN | TPM_HORIZONTAL; + anchorPoint = {targetPos.X, targetBottom}; + flags = TPM_RIGHTALIGN | TPM_BOTTOMALIGN | TPM_HORIZONTAL; } else if (m_directionalHint == DirectionalHint::TopLeftEdge) { - flags = TPM_LEFTALIGN | TPM_TOPALIGN | TPM_VERTICAL; + anchorPoint = {targetPos.X, targetPos.Y}; + flags = TPM_LEFTALIGN | TPM_BOTTOMALIGN | TPM_VERTICAL; } else if (m_directionalHint == DirectionalHint::TopAutoEdge) { - flags = TPM_LEFTALIGN | TPM_TOPALIGN | TPM_VERTICAL; + anchorPoint = {targetPos.X, targetPos.Y}; + flags = TPM_LEFTALIGN | TPM_BOTTOMALIGN | TPM_VERTICAL; } else if (m_directionalHint == DirectionalHint::TopCenter) { - flags = TPM_CENTERALIGN | TPM_TOPALIGN | TPM_VERTICAL; + anchorPoint = {targetCenterX, targetPos.Y}; + flags = TPM_CENTERALIGN | TPM_BOTTOMALIGN | TPM_VERTICAL; } else if (m_directionalHint == DirectionalHint::TopRightEdge) { - flags = TPM_RIGHTALIGN | TPM_TOPALIGN | TPM_VERTICAL; + anchorPoint = {targetRight, targetPos.Y}; + flags = TPM_RIGHTALIGN | TPM_BOTTOMALIGN | TPM_VERTICAL; } else if (m_directionalHint == DirectionalHint::RightTopEdge) { - flags = TPM_RIGHTALIGN | TPM_TOPALIGN | TPM_HORIZONTAL; + anchorPoint = {targetRight, targetPos.Y}; + flags = TPM_LEFTALIGN | TPM_TOPALIGN | TPM_HORIZONTAL; } else if (m_directionalHint == DirectionalHint::RightCenter) { - flags = TPM_RIGHTALIGN | TPM_VCENTERALIGN | TPM_HORIZONTAL; + anchorPoint = {targetRight, targetCenterY}; + flags = TPM_LEFTALIGN | TPM_VCENTERALIGN | TPM_HORIZONTAL; } else if (m_directionalHint == DirectionalHint::RightBottomEdge) { - flags = TPM_RIGHTALIGN | TPM_BOTTOMALIGN | TPM_HORIZONTAL; + anchorPoint = {targetRight, targetBottom}; + flags = TPM_LEFTALIGN | TPM_BOTTOMALIGN | TPM_HORIZONTAL; } else if (m_directionalHint == DirectionalHint::BottomLeftEdge) { - flags = TPM_LEFTALIGN | TPM_BOTTOMALIGN | TPM_VERTICAL; + anchorPoint = {targetPos.X, targetBottom}; + flags = TPM_LEFTALIGN | TPM_TOPALIGN | TPM_VERTICAL; } else if (m_directionalHint == DirectionalHint::BottomAutoEdge) { - flags = TPM_LEFTALIGN | TPM_BOTTOMALIGN | TPM_VERTICAL; + anchorPoint = {targetPos.X, targetBottom}; + flags = TPM_LEFTALIGN | TPM_TOPALIGN | TPM_VERTICAL; } else if (m_directionalHint == DirectionalHint::BottomCenter) { - flags = TPM_CENTERALIGN | TPM_BOTTOMALIGN | TPM_VERTICAL; + anchorPoint = {targetCenterX, targetBottom}; + flags = TPM_CENTERALIGN | TPM_TOPALIGN | TPM_VERTICAL; } else if (m_directionalHint == DirectionalHint::BottomRightEdge) { - flags = TPM_RIGHTALIGN | TPM_BOTTOMALIGN | TPM_VERTICAL; + anchorPoint = {targetRight, targetBottom}; + flags = TPM_RIGHTALIGN | TPM_TOPALIGN | TPM_VERTICAL; } flags |= TPM_WORKAREA;