diff --git a/.changeset/calm-buttons-focus.md b/.changeset/calm-buttons-focus.md new file mode 100644 index 00000000000..1636c9ccf34 --- /dev/null +++ b/.changeset/calm-buttons-focus.md @@ -0,0 +1,5 @@ +--- +"@fluentui-react-native/components": patch +--- + +Render persistent single- and dual-ring focus visuals across agentic components to avoid React Native Windows Fabric crashes. diff --git a/.github/skills/agentic-component-authoring/references/styles-and-tokens.md b/.github/skills/agentic-component-authoring/references/styles-and-tokens.md index a043d86cac4..b8b8c3be385 100644 --- a/.github/skills/agentic-component-authoring/references/styles-and-tokens.md +++ b/.github/skills/agentic-component-authoring/references/styles-and-tokens.md @@ -119,6 +119,24 @@ Preserve consumer slot behavior unless the component owns it. Button no longer f toggle container use `flexShrink` so constrained labels can wrap. A consumer can still request truncation through the content slot. +## Keep focus visuals mounted + +Agentic focusable components render `FocusVisual` inside the interactive slot. Configure its outer +ring and optional inner ring from the component's semantic focus tokens and resolved radius, but keep both configured +Views mounted at rest. `FocusVisual` changes only opacity when focus changes and owns accessibility and hit testing. + +Do not apply React Native `outline*` props conditionally and do not enable the RNW native focus ring. RNW 0.81 Fabric +creates both through a late `BorderPrimitive`; on a background-filled target its owning-root bookkeeping can insert at +index 1 in an empty visual and fail-fast. A style helper alone is insufficient because the invariant is native View +lifetime. + +Keep the ring policy local to the higher-order component: + +- choose single versus dual rings from the component specification +- resolve colors, widths, radius, and positioning from its tokens and variants +- place the visual inside the actual focus target +- keep functional component borders separate from focus feedback + ## Selected text without layout shift When selected text changes weight: diff --git a/apps/storybook/package.json b/apps/storybook/package.json index 7994f70422a..e260c0e8ec3 100644 --- a/apps/storybook/package.json +++ b/apps/storybook/package.json @@ -17,7 +17,7 @@ "macos:build": "xcodebuild -workspace macos/AgenticStorybook.xcworkspace -scheme AgenticStorybook -configuration Debug -destination 'platform=macOS' -derivedDataPath macos/DerivedData CODE_SIGNING_ALLOWED=NO build", "macos:build:clean": "xcodebuild -workspace macos/AgenticStorybook.xcworkspace -scheme AgenticStorybook -configuration Debug -destination 'platform=macOS' -derivedDataPath macos/DerivedData CODE_SIGNING_ALLOWED=NO clean build", "macos": "rnx-cli run --platform macos", - "windows": "pwsh -NoProfile -File scripts/start-windows-agent-session.ps1", + "windows": "rnx-cli run --platform windows", "windows:cli": "react-native run-windows --arch x64 --sln windows/AgenticStorybook.sln", "windows:info": "react-native run-windows --info --no-telemetry", "windows:build": "react-native run-windows --arch x64 --sln windows/AgenticStorybook.sln --no-packager --no-deploy --no-launch --logging --no-telemetry --buildLogDirectory artifacts/windows/build-logs", diff --git a/apps/storybook/windows-tests/storybook-smoke.test.cjs b/apps/storybook/windows-tests/storybook-smoke.test.cjs index fd86a3b737d..5d273074844 100644 --- a/apps/storybook/windows-tests/storybook-smoke.test.cjs +++ b/apps/storybook/windows-tests/storybook-smoke.test.cjs @@ -33,3 +33,49 @@ test.each(smokeStories)('renders $storyId with stable native selectors', async ( `${JSON.stringify({ storyId, testId, displayed, statusTestId, statusText }, null, 2)}\n`, ); }); + +test('moves focus between Button Overview controls after a click', async () => { + await selectStory('components-button--overview'); + + const primary = await app.findElementByTestID('agentic-storybook-button-overview-primary'); + const secondary = await app.findElementByTestID('agentic-storybook-button-overview-secondary'); + await primary.waitForDisplayed({ timeout: 30000 }); + await secondary.waitForDisplayed({ timeout: 30000 }); + + await primary.click(); + await browser.keys(['\uE004']); + + expect(await secondary.getAttribute('HasKeyboardFocus')).toBe('True'); +}); + +test.each([ + ['components-tag--default', 'agentic-storybook-tag'], + ['components-accordion--default', 'accordion-header'], + ['components-tab--selected', 'agentic-storybook-tab-selected'], + ['components-listboxitem--default', 'agentic-storybook-listbox-item'], + ['components-checkbox--default', 'agentic-storybook-checkbox'], + ['components-menuitem--selected', 'agentic-storybook-menu-item'], + ['components-listitem--selected-focus', 'agentic-storybook-list-item-selected'], + ['components-radio--default', 'agentic-storybook-radio'], + ['components-switch--default', 'agentic-storybook-switch'], +])('focuses %s without terminating the app', async (storyId, testId) => { + await selectStory(storyId); + + const element = await app.findElementByTestID(testId); + await element.waitForDisplayed({ timeout: 30000 }); + await element.click(); + await new Promise((resolve) => setTimeout(resolve, 3000)); + + expect(await element.getAttribute('HasKeyboardFocus')).toBe('True'); +}); + +test('focuses the interactive Card without terminating the app', async () => { + await selectStory('components-card--interactive'); + + const card = await app.findElementByXPath('//Button[@Name="Open report"]'); + await card.waitForDisplayed({ timeout: 30000 }); + await card.click(); + await new Promise((resolve) => setTimeout(resolve, 3000)); + + expect(await card.getAttribute('HasKeyboardFocus')).toBe('True'); +}); diff --git a/packages/agentic-components/src/components/AGENTS.md b/packages/agentic-components/src/components/AGENTS.md index 47495ef9b99..ab64657e3e5 100644 --- a/packages/agentic-components/src/components/AGENTS.md +++ b/packages/agentic-components/src/components/AGENTS.md @@ -33,6 +33,8 @@ audit. - Create style factories only at module scope and cache theme-only styles. - Declare state precedence explicitly; disabled wins over pressed, which wins over hovered. - Apply user styles after component styles. +- Render focus feedback through `FocusVisual`; do not add `outline*` props or enable RNW native + focus visuals because RNW 0.81 can fail-fast when either path creates border visuals after mount. - Keep render functions free of hooks, token reads, style creation, and slot mutation. - Export the resolved state type and the state, style-application, and render stages from the package root under component-qualified unstable names so another component can reuse the pipeline. diff --git a/packages/agentic-components/src/components/accordion/accordion.styles.ts b/packages/agentic-components/src/components/accordion/accordion.styles.ts index e349772134f..78fb2642932 100644 --- a/packages/agentic-components/src/components/accordion/accordion.styles.ts +++ b/packages/agentic-components/src/components/accordion/accordion.styles.ts @@ -115,20 +115,6 @@ function createBodyLayoutStyleDefinition({ spacing }: FlexTokens): StyleDefiniti const getBodyLayoutStyle = getThemedStateStyleFactory('Accordion.bodyLayout', createBodyLayoutStyleDefinition, sizeStateLevels); -function createHeaderFocusStyleDefinition({ color, strokeWidth }: FlexTokens): StyleDefinition { - return { - focused: { - borderColor: color.strokeFocusInner, - outlineColor: color.strokeFocusOuter, - outlineOffset: strokeWidth.thin, - outlineStyle: 'solid', - outlineWidth: strokeWidth.thick, - }, - }; -} - -const getHeaderFocusStyle = getThemedStateStyleFactory('Accordion.headerFocus', createHeaderFocusStyleDefinition, [['focused']]); - const headerBackgroundStateLevels = [['pressed', 'hovered']] as const; type HeaderBackgroundStateLevels = typeof headerBackgroundStateLevels; @@ -226,10 +212,6 @@ export function getAccordionHeaderLayoutStyle(state: AccordionState): ViewStyle return getHeaderLayoutStyle(state, [state.size]); } -export function getAccordionHeaderFocusStyle(state: AccordionState): ViewStyle | undefined { - return state.focused ? getHeaderFocusStyle(state, ['focused']) : undefined; -} - export function getAccordionHeaderColorStyles(state: AccordionState): { background: ViewColorStyle; foreground: TextColorStyle; diff --git a/packages/agentic-components/src/components/accordion/accordion.test.tsx b/packages/agentic-components/src/components/accordion/accordion.test.tsx index 54c8ce3feb1..657353886c5 100644 --- a/packages/agentic-components/src/components/accordion/accordion.test.tsx +++ b/packages/agentic-components/src/components/accordion/accordion.test.tsx @@ -93,15 +93,19 @@ describe('Accordion', () => { expect(getHeader(component).props.accessibilityState).toEqual({ busy: true, expanded: false }); }); - it('uses the focused prop to render the universal dual-outline focus ring', async () => { + it('uses the focused prop to render the universal dual-ring focus visual', async () => { const component = await renderAccordion({ focused: true }); - expect(getHeaderStyle(component)).toMatchObject({ + expect(StyleSheet.flatten(component.getByTestId('focus-visual', { includeHiddenElements: true }).props.style)).toMatchObject({ + borderColor: '#000000', + borderWidth: 2, + }); + expect(StyleSheet.flatten(component.getByTestId('focus-visual', { includeHiddenElements: true }).props.style)).not.toHaveProperty( + 'opacity', + ); + expect(StyleSheet.flatten(component.getByTestId('focus-visual-inner', { includeHiddenElements: true }).props.style)).toMatchObject({ borderColor: '#ffffff', - outlineColor: '#000000', - outlineOffset: 1, - outlineStyle: 'solid', - outlineWidth: 2, + borderWidth: 1, }); }); @@ -134,8 +138,18 @@ describe('Accordion', () => { const startChildren = getHeader(start).children as { props: { testID?: string } }[]; const endChildren = getHeader(end).children as { props: { testID?: string } }[]; - expect(startChildren.map((child) => child.props.testID)).toEqual(['accordion-chevron', 'accordion-leading-icon', 'accordion-title']); - expect(endChildren.map((child) => child.props.testID)).toEqual(['accordion-leading-icon', 'accordion-title', 'accordion-chevron']); + expect(startChildren.map((child) => child.props.testID)).toEqual([ + 'focus-visual', + 'accordion-chevron', + 'accordion-leading-icon', + 'accordion-title', + ]); + expect(endChildren.map((child) => child.props.testID)).toEqual([ + 'focus-visual', + 'accordion-leading-icon', + 'accordion-title', + 'accordion-chevron', + ]); }); it('renders custom title and body slots', async () => { diff --git a/packages/agentic-components/src/components/accordion/accordion.types.ts b/packages/agentic-components/src/components/accordion/accordion.types.ts index ca4f26ea081..8b09ec5dea6 100644 --- a/packages/agentic-components/src/components/accordion/accordion.types.ts +++ b/packages/agentic-components/src/components/accordion/accordion.types.ts @@ -9,6 +9,7 @@ import type { } from '@fluentui-react-native/framework-base'; import type { ThemeState } from '@fluentui-react-native/design'; import type { Icon } from '../../primitives/icon/icon'; +import type { FocusVisualProps } from '../../primitives/focus-visual/focus-visual.types'; export type AccordionLayout = 'chevronStart' | 'chevronEnd'; export type AccordionSize = 'small'; @@ -95,6 +96,7 @@ export type AccordionState = ComponentState & ThemeState & PressableState & { expanded: boolean; + focusVisualProps?: FocusVisualProps; focused: boolean; userStyle?: StyleProp; }; diff --git a/packages/agentic-components/src/components/accordion/renderAccordion.tsx b/packages/agentic-components/src/components/accordion/renderAccordion.tsx index b816d8409e0..3853b48d92c 100644 --- a/packages/agentic-components/src/components/accordion/renderAccordion.tsx +++ b/packages/agentic-components/src/components/accordion/renderAccordion.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react'; import { Text } from 'react-native'; import type { StyleProp, TextStyle } from 'react-native'; +import { FocusVisual } from '../../primitives/focus-visual/focus-visual'; import type { AccordionState } from './accordion.types'; type AccordionRenderStyles = { @@ -28,6 +29,7 @@ export function renderAccordion_unstable(state: AccordionState, styles: Accordio return (
+ {layout === 'chevronStart' ? ( <> diff --git a/packages/agentic-components/src/components/accordion/useAccordionStyles.ts b/packages/agentic-components/src/components/accordion/useAccordionStyles.ts index 31f712c2651..b828af7357e 100644 --- a/packages/agentic-components/src/components/accordion/useAccordionStyles.ts +++ b/packages/agentic-components/src/components/accordion/useAccordionStyles.ts @@ -1,6 +1,7 @@ import type { StyleProp, TextStyle, ViewStyle } from 'react-native'; import { attachSlotProps } from '@fluentui-react-native/framework-base'; +import { createFocusVisualProps } from '../../primitives/focus-visual/focus-visual'; import { accordionStyles, @@ -9,7 +10,6 @@ import { getAccordionBodyTypographyStyle, getAccordionChevronLayoutStyle, getAccordionHeaderColorStyles, - getAccordionHeaderFocusStyle, getAccordionHeaderLayoutStyle, getAccordionIconSize, getAccordionTitleLayoutStyle, @@ -22,13 +22,9 @@ import type { AccordionState } from './accordion.types'; */ export function useAccordionStyles_unstable(state: AccordionState) { const headerColors = getAccordionHeaderColorStyles(state); + const headerLayoutStyle = getAccordionHeaderLayoutStyle(state); const rootStyle: StyleProp = [accordionStyles.root, state.userStyle]; - const headerStyle: StyleProp = [ - accordionStyles.header, - getAccordionHeaderLayoutStyle(state), - headerColors.background, - getAccordionHeaderFocusStyle(state), - ]; + const headerStyle: StyleProp = [accordionStyles.header, headerLayoutStyle, headerColors.background]; const titleStyle: StyleProp = [ accordionStyles.title, getAccordionTitleLayoutStyle(state), @@ -55,6 +51,14 @@ export function useAccordionStyles_unstable(state: AccordionState) { ]; const iconSize = getAccordionIconSize(); + state.focusVisualProps = createFocusVisualProps({ + borderRadius: headerLayoutStyle.borderRadius, + innerColor: state.tokens.color.strokeFocusInner, + innerWidth: state.tokens.strokeWidth.thin, + outerColor: state.tokens.color.strokeFocusOuter, + outerWidth: state.tokens.strokeWidth.thick, + visible: state.focused, + }); attachSlotProps(state.root, { style: rootStyle }); attachSlotProps(state.header, { style: headerStyle }); if (state.title) { diff --git a/packages/agentic-components/src/components/button/__snapshots__/button.test.tsx.snap b/packages/agentic-components/src/components/button/__snapshots__/button.test.tsx.snap index 3dc8ce98422..ef7c16b7d60 100644 --- a/packages/agentic-components/src/components/button/__snapshots__/button.test.tsx.snap +++ b/packages/agentic-components/src/components/button/__snapshots__/button.test.tsx.snap @@ -144,7 +144,7 @@ exports[`Button matches the focused visual state snapshot across appearances 1`] "alignItems": "center", "alignSelf": "flex-start", "backgroundColor": "#185abd", - "borderColor": "#ffffff", + "borderColor": "#00000000", "borderRadius": 4, "borderStyle": "solid", "borderWidth": 1, @@ -153,10 +153,6 @@ exports[`Button matches the focused visual state snapshot across appearances 1`] "justifyContent": "center", "minHeight": 24, "minWidth": 24, - "outlineColor": "#000000", - "outlineOffset": 1, - "outlineStyle": "solid", - "outlineWidth": 2, "paddingHorizontal": 10, "paddingVertical": 6, }, @@ -178,7 +174,7 @@ exports[`Button matches the focused visual state snapshot across appearances 1`] "alignItems": "center", "alignSelf": "flex-start", "backgroundColor": "#fafafa", - "borderColor": "#ffffff", + "borderColor": "#00000000", "borderRadius": 4, "borderStyle": "solid", "borderWidth": 1, @@ -187,10 +183,6 @@ exports[`Button matches the focused visual state snapshot across appearances 1`] "justifyContent": "center", "minHeight": 24, "minWidth": 24, - "outlineColor": "#000000", - "outlineOffset": 1, - "outlineStyle": "solid", - "outlineWidth": 2, "paddingHorizontal": 10, "paddingVertical": 6, }, @@ -212,7 +204,7 @@ exports[`Button matches the focused visual state snapshot across appearances 1`] "alignItems": "center", "alignSelf": "flex-start", "backgroundColor": "#00000000", - "borderColor": "#ffffff", + "borderColor": "#d1d1d1", "borderRadius": 4, "borderStyle": "solid", "borderWidth": 1, @@ -221,10 +213,6 @@ exports[`Button matches the focused visual state snapshot across appearances 1`] "justifyContent": "center", "minHeight": 24, "minWidth": 24, - "outlineColor": "#000000", - "outlineOffset": 1, - "outlineStyle": "solid", - "outlineWidth": 2, "paddingHorizontal": 10, "paddingVertical": 6, }, @@ -246,7 +234,7 @@ exports[`Button matches the focused visual state snapshot across appearances 1`] "alignItems": "center", "alignSelf": "flex-start", "backgroundColor": "#00000000", - "borderColor": "#ffffff", + "borderColor": "#00000000", "borderRadius": 4, "borderStyle": "solid", "borderWidth": 1, @@ -255,10 +243,6 @@ exports[`Button matches the focused visual state snapshot across appearances 1`] "justifyContent": "center", "minHeight": 24, "minWidth": 24, - "outlineColor": "#000000", - "outlineOffset": 1, - "outlineStyle": "solid", - "outlineWidth": 2, "paddingHorizontal": 10, "paddingVertical": 6, }, diff --git a/packages/agentic-components/src/components/button/button.stories.tsx b/packages/agentic-components/src/components/button/button.stories.tsx index 8abd981013e..23d37df9da6 100644 --- a/packages/agentic-components/src/components/button/button.stories.tsx +++ b/packages/agentic-components/src/components/button/button.stories.tsx @@ -82,7 +82,7 @@ export const Overview: Story = { {appearances.map(({ label, value }) => ( -