Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-buttons-focus.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion apps/storybook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
46 changes: 46 additions & 0 deletions apps/storybook/windows-tests/storybook-smoke.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
2 changes: 2 additions & 0 deletions packages/agentic-components/src/components/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,20 +115,6 @@ function createBodyLayoutStyleDefinition({ spacing }: FlexTokens): StyleDefiniti

const getBodyLayoutStyle = getThemedStateStyleFactory('Accordion.bodyLayout', createBodyLayoutStyleDefinition, sizeStateLevels);

function createHeaderFocusStyleDefinition({ color, strokeWidth }: FlexTokens): StyleDefinition<ViewStyle, [['focused']]> {
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;

Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
});

Expand Down Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -95,6 +96,7 @@ export type AccordionState = ComponentState<AccordionStateSlots> &
ThemeState &
PressableState & {
expanded: boolean;
focusVisualProps?: FocusVisualProps;
focused: boolean;
userStyle?: StyleProp<ViewStyle>;
};
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -28,6 +29,7 @@ export function renderAccordion_unstable(state: AccordionState, styles: Accordio
return (
<state.root>
<Header>
<FocusVisual {...state.focusVisualProps} />
{layout === 'chevronStart' ? (
<>
<ChevronContainer>
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -9,7 +10,6 @@ import {
getAccordionBodyTypographyStyle,
getAccordionChevronLayoutStyle,
getAccordionHeaderColorStyles,
getAccordionHeaderFocusStyle,
getAccordionHeaderLayoutStyle,
getAccordionIconSize,
getAccordionTitleLayoutStyle,
Expand All @@ -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<ViewStyle> = [accordionStyles.root, state.userStyle];
const headerStyle: StyleProp<ViewStyle> = [
accordionStyles.header,
getAccordionHeaderLayoutStyle(state),
headerColors.background,
getAccordionHeaderFocusStyle(state),
];
const headerStyle: StyleProp<ViewStyle> = [accordionStyles.header, headerLayoutStyle, headerColors.background];
const titleStyle: StyleProp<TextStyle> = [
accordionStyles.title,
getAccordionTitleLayoutStyle(state),
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
},
Expand All @@ -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,
Expand All @@ -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,
},
Expand All @@ -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,
Expand All @@ -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,
},
Expand All @@ -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,
Expand All @@ -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,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export const Overview: Story = {
<View style={styles.story}>
<StoryGroup label="Appearance">
{appearances.map(({ label, value }) => (
<Button key={value} appearance={value} content={label} />
<Button key={value} appearance={value} content={label} testID={`agentic-storybook-button-overview-${value}`} />
))}
</StoryGroup>
<StoryGroup label="Size">
Expand Down
22 changes: 0 additions & 22 deletions packages/agentic-components/src/components/button/button.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,28 +297,6 @@ export function getButtonContentStyle(state: ButtonState, selected = state.selec
return getThemedContentStyle(state, source);
}

const focusStyleStateLevels = [['focused']] as const;

const getThemedFocusStyle = getThemedStateStyleFactory(
'Button.focus',
({ color, strokeWidth }: FlexTokens): StyleDefinition<ViewStyle, typeof focusStyleStateLevels> => ({
focused: {
borderColor: color.strokeFocusInner,
outlineColor: color.strokeFocusOuter,
outlineOffset: strokeWidth.thin,
outlineStyle: 'solid',
outlineWidth: strokeWidth.thick,
},
}),
focusStyleStateLevels,
);

const focusedState = ['focused'] as const;

export function getButtonFocusStyle(state: ButtonState): ViewStyle | undefined {
return state.focused && !state.disabled ? getThemedFocusStyle(state, focusedState) : undefined;
}

const iconSizes: Record<ButtonSize, number> = {
small: size160,
medium: size200,
Expand Down
Loading
Loading