diff --git a/.changeset/bright-refs-flow.md b/.changeset/bright-refs-flow.md new file mode 100644 index 0000000000..5719184273 --- /dev/null +++ b/.changeset/bright-refs-flow.md @@ -0,0 +1,5 @@ +--- +"@fluentui-react-native/components": minor +--- + +Expose React 19 ref props for components and primitives with stable native roots. diff --git a/.github/skills/agentic-component-authoring/references/rendering.md b/.github/skills/agentic-component-authoring/references/rendering.md index 1ea898c5f1..4bf1c6dce2 100644 --- a/.github/skills/agentic-component-authoring/references/rendering.md +++ b/.github/skills/agentic-component-authoring/references/rendering.md @@ -59,6 +59,10 @@ Set `displayName` for diagnostics and Storybook metadata. Keep unstable stage fu future composition work can identify them. Do not wrap the component in memoization or another boundary without measured need and repository precedent. +Under this package's React 19.1.4 baseline, `ref` is an ordinary component prop. Include it in the public root props and +let the state hook forward it to the resolved root slot; do not wrap assembly components in `forwardRef`. If rendering a +second internal ref on the same slot, pass it in JSX so the slot runtime composes it with the captured consumer ref. + After assembly, export `use_unstable`, `useStyles_unstable`, and `render_unstable` from the package root together with `State`, according to the [types and slots export rules](types-and-slots.md#exports). These stages are intentionally unstable but public so other diff --git a/.github/skills/agentic-component-authoring/references/types-and-slots.md b/.github/skills/agentic-component-authoring/references/types-and-slots.md index 22288e3b70..5d4f9aaa7b 100644 --- a/.github/skills/agentic-component-authoring/references/types-and-slots.md +++ b/.github/skills/agentic-component-authoring/references/types-and-slots.md @@ -34,12 +34,12 @@ enables behavior; Button uses `selected !== undefined` to distinguish an ordinar Do not blindly inherit every root prop when the component owns part of the native contract. -1. Start with the root native props. +1. Start with `PropsWithRefOf` so the native root's ref is part of the public contract. 2. Omit props the component controls, such as `children` or token-derived `style`. 3. Reintroduce a narrowed form only when consumers need it. -Button uses `Omit` and adds `StyleProp` back explicitly. This prevents -native children from bypassing slot order while preserving a user style that can be applied after component styles. +Button uses `OwnedRootProps>`. This prevents native children from bypassing slot order, +preserves the native style type, and retains `ref`. Compose the final public props with: @@ -47,6 +47,14 @@ Compose the final public props with: export type MyComponentProps = MyComponentStateProps & ComponentProps; ``` +This package targets React 19.1.4 or newer, where function components receive `ref` as a prop. Let the state hook carry +that prop to the declared root slot; do not wrap the component in `forwardRef`. A top-level component ref always targets +the declared root. Consumers that need an inner public slot use that slot's own `ref`. + +When a component needs an internal root ref as well, keep both refs and pass one through the resolved slot and the other +when rendering the slot so Framework Base composes them. Never overwrite the consumer ref. If a primitive can render +unrelated native instance types, omit a top-level ref unless it can define one sound, stable imperative contract. + ## Make resolved state complete Build state from: @@ -89,5 +97,6 @@ internal style definitions. - Variant unions match the spec exactly. - Omitted and false values retain distinct semantics where required. - Root native props cannot bypass owned children or style ordering. +- Stable native roots expose a correctly typed `ref`, and the state/render pipeline does not replace it. - Resolved state contains every defaulted and derived value needed downstream. - Public component, state, style-application, render, and resolved-state exports are explicit and tree-shakeable. diff --git a/packages/agentic/components/AGENTS.md b/packages/agentic/components/AGENTS.md index dec9ed6a9f..b36a31d51a 100644 --- a/packages/agentic/components/AGENTS.md +++ b/packages/agentic/components/AGENTS.md @@ -29,6 +29,8 @@ invariants; detailed authoring recipes live in the - Use `src/components/button` as the canonical higher-order implementation and `src/primitives/icon` as the canonical primitive. - Keep public props and slots small, typed, and spec-driven. +- This package targets React 19.1.4 or newer. Expose a stable native root ref through the `ref` prop, receive it as part + of the component props, and forward it to the declared root slot. Do not use `forwardRef`. - Export higher-order components and public types explicitly from `src/index.ts`; export primitives and their public types explicitly from `src/primitives/index.ts`. Never use wildcard exports. - Colocate runtime tests, type tests, and Storybook stories with the implementation. diff --git a/packages/agentic/components/package.json b/packages/agentic/components/package.json index caa001bb29..66997652eb 100644 --- a/packages/agentic/components/package.json +++ b/packages/agentic/components/package.json @@ -47,6 +47,7 @@ "@babel/core": "catalog:", "@fluentui-react-native/desktop-driver": "workspace:*", "@fluentui-react-native/scripts": "workspace:*", + "@office-iss/react-native-win32": "^0.81.0", "@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", @@ -58,16 +59,17 @@ "react-native": "^0.81.6", "react-native-macos": "^0.81.0", "react-native-svg": "^15.12.1", + "react-native-windows": "^0.81.0", "test-renderer": "catalog:" }, "peerDependencies": { - "@office-iss/react-native-win32": "^0.74.0 || ^0.81.0", - "@types/react": "~18.2.0 || ~19.0.0 || ~19.1.4", - "react": "18.2.0 || 19.0.0 || 19.1.4", - "react-native": "^0.73.0 || ^0.74.0 || ^0.78.0 || ^0.81.6", - "react-native-macos": "^0.73.0 || ^0.74.0 || ^0.78.0 || ^0.81.0", - "react-native-svg": ">=15.0.0 <15.13.0 || >=15.4.0 <15.13.0 || ^15.11.2 || ^15.12.1", - "react-native-windows": "^0.73.0 || ^0.74.0 || ^0.78.0 || ^0.81.0" + "@office-iss/react-native-win32": "^0.81.0", + "@types/react": "~19.1.4", + "react": "19.1.4", + "react-native": "^0.81.6", + "react-native-macos": "^0.81.0", + "react-native-svg": "^15.12.1", + "react-native-windows": "^0.81.0" }, "peerDependenciesMeta": { "@office-iss/react-native-win32": { @@ -94,9 +96,16 @@ "rnx-kit": { "kitType": "library", "alignDeps": { + "requirements": { + "production": [ + "react-native@0.81" + ] + }, "capabilities": [ "core", "core-macos", + "core-win32", + "core-windows", "svg" ] }, diff --git a/packages/agentic/components/src/components/AGENTS.md b/packages/agentic/components/src/components/AGENTS.md index 6714cd8ed4..4de5e7d6c2 100644 --- a/packages/agentic/components/src/components/AGENTS.md +++ b/packages/agentic/components/src/components/AGENTS.md @@ -38,6 +38,9 @@ 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. +- Include the declared native root's `ref` in public props with `PropsWithRefOf`. React 19.1.4 supplies + `ref` as a prop, so pass it through the state hook to the root slot without `forwardRef`. If the component also needs + an internal root ref, compose the refs through the slot render path rather than replacing the consumer ref. - 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. diff --git a/packages/agentic/components/src/components/accordion/accordion.types.ts b/packages/agentic/components/src/components/accordion/accordion.types.ts index 8b09ec5dea..cc232c7d42 100644 --- a/packages/agentic/components/src/components/accordion/accordion.types.ts +++ b/packages/agentic/components/src/components/accordion/accordion.types.ts @@ -1,10 +1,11 @@ -import type { AccessibilityState, Pressable, StyleProp, Text, View, ViewProps, ViewStyle } from 'react-native'; +import type { AccessibilityState, Pressable, StyleProp, Text, View, ViewStyle } from 'react-native'; import type { ComponentProps, ComponentState, OptionalSlot, OwnedRootProps, PressableState, + PropsWithRefOf, Slot, } from '@fluentui-react-native/framework-base'; import type { ThemeState } from '@fluentui-react-native/design'; @@ -85,7 +86,7 @@ export type AccordionStateProps = { }; export type AccordionRootProps = OwnedRootProps< - ViewProps, + PropsWithRefOf, 'accessible' | 'accessibilityHint' | 'accessibilityLabel' | 'accessibilityRole' | 'accessibilityState' | 'focusable' >; diff --git a/packages/agentic/components/src/components/avatar/avatar.types.ts b/packages/agentic/components/src/components/avatar/avatar.types.ts index 7c07102073..caa075e7cd 100644 --- a/packages/agentic/components/src/components/avatar/avatar.types.ts +++ b/packages/agentic/components/src/components/avatar/avatar.types.ts @@ -1,5 +1,12 @@ -import type { Image, StyleProp, Text, View, ViewProps, ViewStyle } from 'react-native'; -import type { ComponentProps, ComponentState, OptionalSlot, OwnedRootProps, Slot } from '@fluentui-react-native/framework-base'; +import type { Image, StyleProp, Text, View, ViewStyle } from 'react-native'; +import type { + ComponentProps, + ComponentState, + OptionalSlot, + OwnedRootProps, + PropsWithRefOf, + Slot, +} from '@fluentui-react-native/framework-base'; import type { ThemeState } from '@fluentui-react-native/design'; import type { Icon } from '../../primitives/icon/icon'; @@ -26,7 +33,7 @@ export type AvatarStateProps = { size?: AvatarSize; }; -export type AvatarRootProps = OwnedRootProps; +export type AvatarRootProps = OwnedRootProps>; export type AvatarProps = AvatarStateProps & ComponentProps; diff --git a/packages/agentic/components/src/components/badge/badge.types.ts b/packages/agentic/components/src/components/badge/badge.types.ts index fea9a80620..f77d4a23ee 100644 --- a/packages/agentic/components/src/components/badge/badge.types.ts +++ b/packages/agentic/components/src/components/badge/badge.types.ts @@ -1,5 +1,13 @@ -import type { StyleProp, Text, View, ViewProps, ViewStyle } from 'react-native'; -import type { ComponentProps, ComponentState, OptionalSlot, OwnedRootProps, Slot, SlotProp } from '@fluentui-react-native/framework-base'; +import type { StyleProp, Text, View, ViewStyle } from 'react-native'; +import type { + ComponentProps, + ComponentState, + OptionalSlot, + OwnedRootProps, + PropsWithRefOf, + Slot, + SlotProp, +} from '@fluentui-react-native/framework-base'; import type { ThemeState } from '@fluentui-react-native/design'; import type { Icon } from '../../primitives/icon/icon'; @@ -59,7 +67,7 @@ type BadgeIconOnlyProps = BadgeCommonProps & { export type BadgeStateProps = BadgeIconAndTextProps | BadgeIconOnlyProps; -export type BadgeExposedViewProps = OwnedRootProps; +export type BadgeExposedViewProps = OwnedRootProps, 'accessibilityRole' | 'focusable'>; export type BadgeProps = BadgeStateProps & ComponentProps; diff --git a/packages/agentic/components/src/components/button/button.test.tsx b/packages/agentic/components/src/components/button/button.test.tsx index 7c16c517c9..2c3585a3cd 100644 --- a/packages/agentic/components/src/components/button/button.test.tsx +++ b/packages/agentic/components/src/components/button/button.test.tsx @@ -1,6 +1,7 @@ /** @jsxImportSource @fluentui-react-native/framework-base */ +import * as React from 'react'; import { StyleSheet, View } from 'react-native'; -import type { PressableProps, ViewStyle } from 'react-native'; +import type { Pressable, PressableProps, ViewStyle } from 'react-native'; import { fireEvent, render } from '@testing-library/react-native'; import type { RenderResult } from '@testing-library/react-native'; @@ -23,6 +24,14 @@ function getRootStyle(component: RenderResult): ViewStyle { } describe('Button', () => { + it('forwards its ref prop to the native root', async () => { + const ref = React.createRef>(); + + await renderButton({ content: 'Save', ref }); + + expect(ref.current).not.toBeNull(); + }); + it('reuses cached theme styles without recreating them for another button instance', async () => { const createStyleSheet = jest.spyOn(StyleSheet, 'create'); diff --git a/packages/agentic/components/src/components/button/button.types.ts b/packages/agentic/components/src/components/button/button.types.ts index 26722faf0e..117d909629 100644 --- a/packages/agentic/components/src/components/button/button.types.ts +++ b/packages/agentic/components/src/components/button/button.types.ts @@ -1,4 +1,4 @@ -import type { Pressable, PressableProps, StyleProp, Text, ViewStyle } from 'react-native'; +import type { Pressable, StyleProp, Text, ViewStyle } from 'react-native'; import type { Slot, OptionalSlot, @@ -6,6 +6,7 @@ import type { ComponentProps, ComponentState, PressableState, + PropsWithRefOf, } from '@fluentui-react-native/framework-base'; import type { ThemeState } from '@fluentui-react-native/design'; import type { Icon } from '../../primitives/icon/icon'; @@ -78,7 +79,7 @@ export type ButtonStateProps = { * Props that are exposed from the underlying Pressable component at the top level. A button controls its * own children and resolves styles from tokens, so those props are exposed separately. */ -export type ButtonExposedPressableProps = OwnedRootProps; +export type ButtonExposedPressableProps = OwnedRootProps>; /** * Props for the Button component, including state props, slot props, and exposed Pressable props. diff --git a/packages/agentic/components/src/components/button/useButton.ts b/packages/agentic/components/src/components/button/useButton.ts index cf7b7133d5..368642c979 100644 --- a/packages/agentic/components/src/components/button/useButton.ts +++ b/packages/agentic/components/src/components/button/useButton.ts @@ -25,6 +25,7 @@ export function useButton_unstable(props: ButtonProps): ButtonState { disabled = false, icon: iconProp, iconPosition = 'before', + ref: rootRef, selected, selectedIcon: selectedIconProp, shape, @@ -64,7 +65,7 @@ export function useButton_unstable(props: ButtonProps): ButtonState { focusable: rest.focusable ?? !disabled, }; const [pressableProps, pressableState] = usePressableState(nativeProps); - const root = useSlot(Pressable, pressableProps); + const root = useSlot(Pressable, { ...pressableProps, ref: rootRef }); const icon = useOptionalSlot(Icon, iconProp); const selectedIcon = useOptionalSlot(Icon, selectedIconProp); const content = useOptionalSlot(Text, contentProp); diff --git a/packages/agentic/components/src/components/card/card.test.tsx b/packages/agentic/components/src/components/card/card.test.tsx index 26f52d15f8..384d182851 100644 --- a/packages/agentic/components/src/components/card/card.test.tsx +++ b/packages/agentic/components/src/components/card/card.test.tsx @@ -1,4 +1,5 @@ /** @jsxImportSource @fluentui-react-native/framework-base */ +import * as React from 'react'; import { StyleSheet, Text, View } from 'react-native'; import type { ViewStyle } from 'react-native'; @@ -27,6 +28,21 @@ describe('Card', () => { jest.restoreAllMocks(); }); + it('forwards its ref only to the structural native root', async () => { + const ref = jest.fn | null]>(); + + await renderCard({ + accessibilityLabel: 'Open report', + content: { children: Report }, + onPress: jest.fn(), + ref, + testID: 'card-root', + }); + + expect(ref).toHaveBeenCalledTimes(1); + expect(ref).toHaveBeenCalledWith(expect.anything()); + }); + it('renders selection without changing it on press', async () => { const onPress = jest.fn(); const component = await renderCard({ accessibilityLabel: 'Report', onPress, selected: false }); diff --git a/packages/agentic/components/src/components/card/card.types.ts b/packages/agentic/components/src/components/card/card.types.ts index 488e3056c5..01d44494d9 100644 --- a/packages/agentic/components/src/components/card/card.types.ts +++ b/packages/agentic/components/src/components/card/card.types.ts @@ -1,6 +1,13 @@ import type { Pressable, PressableProps, StyleProp, View, ViewStyle } from 'react-native'; -import type { ComponentProps, ComponentState, OptionalSlot, OwnedRootProps, Slot } from '@fluentui-react-native/framework-base'; +import type { + ComponentProps, + ComponentState, + OptionalSlot, + OwnedRootProps, + PropsWithRefOf, + Slot, +} from '@fluentui-react-native/framework-base'; import type { ThemeState } from '@fluentui-react-native/design'; import type { FocusVisualProps } from '../../primitives/focus-visual/focus-visual.types'; @@ -36,7 +43,7 @@ export type CardStateProps = { export type CardExposedPressableProps = OwnedRootProps & { children?: never; -}; +} & Pick, 'ref'>; export type CardProps = CardStateProps & ComponentProps; diff --git a/packages/agentic/components/src/components/card/useCard.ts b/packages/agentic/components/src/components/card/useCard.ts index 2288719f7d..171ceeadd8 100644 --- a/packages/agentic/components/src/components/card/useCard.ts +++ b/packages/agentic/components/src/components/card/useCard.ts @@ -1,7 +1,7 @@ import { Pressable, View, useWindowDimensions } from 'react-native'; -import type { PressableProps, ViewProps } from 'react-native'; +import type { PressableProps } from 'react-native'; -import { usePressableState, useOptionalSlot, useSlot } from '@fluentui-react-native/framework-base'; +import { type PropsWithRefOf, usePressableState, useOptionalSlot, useSlot } from '@fluentui-react-native/framework-base'; import { useThemeState } from '@fluentui-react-native/design'; import type { CardProps, CardState } from './card.types'; @@ -37,6 +37,7 @@ export function useCard_unstable(props: CardProps): CardState { onPressOut, padding = 'default', pressRetentionOffset, + ref: rootRef, selected, size = 'small', style: userStyle, @@ -83,8 +84,10 @@ export function useCard_unstable(props: CardProps): CardState { unstable_pressDelay, } as PressableProps); - const rootProps: ViewProps = isInteractive - ? ({ ...rest, accessible: false, accessibilityState: { ...accessibilityState, disabled }, testID } as ViewProps) + const rootProps: PropsWithRefOf = isInteractive + ? ({ ...rest, accessible: false, accessibilityState: { ...accessibilityState, disabled }, ref: rootRef, testID } as PropsWithRefOf< + typeof View + >) : ({ ...rest, accessibilityLabel, @@ -96,8 +99,9 @@ export function useCard_unstable(props: CardProps): CardState { accessible: accessible ?? false, accessibilityRole: (accessible ?? false) ? 'group' : undefined, focusable: false, + ref: rootRef, testID, - } as ViewProps); + } as PropsWithRefOf); const root = useSlot(View, rootProps); const overlay = useOptionalSlot(Pressable, isInteractive ? overlayProps : null); diff --git a/packages/agentic/components/src/components/checkbox/checkbox.types.ts b/packages/agentic/components/src/components/checkbox/checkbox.types.ts index ba80dd647c..73e9b6111e 100644 --- a/packages/agentic/components/src/components/checkbox/checkbox.types.ts +++ b/packages/agentic/components/src/components/checkbox/checkbox.types.ts @@ -1,9 +1,10 @@ -import type { ColorValue, Pressable, PressableProps, StyleProp, Text, ViewStyle } from 'react-native'; +import type { ColorValue, Pressable, StyleProp, Text, ViewStyle } from 'react-native'; import type { ComponentProps, ComponentState, OptionalSlot, OwnedRootProps, + PropsWithRefOf, Slot, PressableState, } from '@fluentui-react-native/framework-base'; @@ -50,7 +51,7 @@ export type CheckboxStateProps = { */ export type CheckboxStatusDriverKeys = 'defaultStatus' | 'onStatusChange'; -export type CheckboxRootProps = OwnedRootProps & { +export type CheckboxRootProps = OwnedRootProps> & { children?: never; }; diff --git a/packages/agentic/components/src/components/checkbox/useCheckbox.ts b/packages/agentic/components/src/components/checkbox/useCheckbox.ts index 51ee810e88..4fb2b61d5f 100644 --- a/packages/agentic/components/src/components/checkbox/useCheckbox.ts +++ b/packages/agentic/components/src/components/checkbox/useCheckbox.ts @@ -25,6 +25,7 @@ export function useCheckbox_unstable(props: CheckboxProps): CheckboxState { label = 'Label', onPress, onStatusChange, + ref: rootRef, secondaryText = 'Description', showLabel = true, showSecondaryText = false, @@ -82,7 +83,7 @@ export function useCheckbox_unstable(props: CheckboxProps): CheckboxState { const [focusVisibleProps, focusVisible] = useFocusVisible(nativeProps); const [pressableProps, pressableState] = usePressableState(focusVisibleProps); - const root = useSlot(Pressable, pressableProps); + const root = useSlot(Pressable, { ...pressableProps, ref: rootRef }); const labelText = useOptionalSlot(Text, showLabel ? { accessible: false, children: label, testID: 'checkbox-label' } : null); const secondaryTextSlot = useOptionalSlot( Text, diff --git a/packages/agentic/components/src/components/divider/divider.types.ts b/packages/agentic/components/src/components/divider/divider.types.ts index 3f487a62a0..c46aacd65c 100644 --- a/packages/agentic/components/src/components/divider/divider.types.ts +++ b/packages/agentic/components/src/components/divider/divider.types.ts @@ -1,6 +1,13 @@ -import type { StyleProp, ViewStyle, ViewProps, Text, View } from 'react-native'; - -import type { ComponentProps, ComponentState, OptionalSlot, OwnedRootProps, Slot } from '@fluentui-react-native/framework-base'; +import type { StyleProp, ViewStyle, Text, View } from 'react-native'; + +import type { + ComponentProps, + ComponentState, + OptionalSlot, + OwnedRootProps, + PropsWithRefOf, + Slot, +} from '@fluentui-react-native/framework-base'; import type { ThemeState } from '@fluentui-react-native/design'; import type { Icon } from '../../primitives/icon/icon'; @@ -22,7 +29,7 @@ export type DividerStateProps = { vertical?: boolean; }; -export type DividerExposedViewProps = OwnedRootProps; +export type DividerExposedViewProps = OwnedRootProps, 'accessibilityRole' | 'focusable'>; export type DividerProps = DividerStateProps & ComponentProps; diff --git a/packages/agentic/components/src/components/input/input.test.tsx b/packages/agentic/components/src/components/input/input.test.tsx index 513ccefd88..ead8c393af 100644 --- a/packages/agentic/components/src/components/input/input.test.tsx +++ b/packages/agentic/components/src/components/input/input.test.tsx @@ -1,6 +1,7 @@ /** @jsxImportSource @fluentui-react-native/framework-base */ +import * as React from 'react'; import { StyleSheet } from 'react-native'; -import type { ViewStyle } from 'react-native'; +import type { View, ViewStyle } from 'react-native'; import { fireEvent, render } from '@testing-library/react-native'; import { defaultFlexTokens } from '@fluentui-react-native/design/testing'; @@ -20,6 +21,14 @@ function flattenStyle(style: unknown) { } describe('Input', () => { + it('forwards its ref prop through phased rendering to the native root', async () => { + const ref = React.createRef>(); + + await render(); + + expect(ref.current).not.toBeNull(); + }); + it('renders a textbox with outline styling by default', async () => { const component = await render(); const textbox = getTextbox(component); diff --git a/packages/agentic/components/src/components/input/input.types.ts b/packages/agentic/components/src/components/input/input.types.ts index 4f249da4af..ec3ccade48 100644 --- a/packages/agentic/components/src/components/input/input.types.ts +++ b/packages/agentic/components/src/components/input/input.types.ts @@ -1,7 +1,14 @@ -import type { AccessibilityState, ColorValue, StyleProp, TextInput, TextStyle, View, ViewProps, ViewStyle } from 'react-native'; +import type { AccessibilityState, ColorValue, StyleProp, TextInput, TextStyle, View, ViewStyle } from 'react-native'; import type { ThemeState } from '@fluentui-react-native/design'; -import type { ComponentProps, ComponentState, OptionalSlot, OwnedRootProps, Slot } from '@fluentui-react-native/framework-base'; +import type { + ComponentProps, + ComponentState, + OptionalSlot, + OwnedRootProps, + PropsWithRefOf, + Slot, +} from '@fluentui-react-native/framework-base'; import type { Icon } from '../../primitives/icon/icon'; @@ -42,7 +49,7 @@ export type InputStateProps = { onPressOut?: (...args: any[]) => void; }; -export type InputExposedRootProps = OwnedRootProps; +export type InputExposedRootProps = OwnedRootProps>; export type InputProps = InputStateProps & ComponentProps; diff --git a/packages/agentic/components/src/components/list-item/list-item.types.ts b/packages/agentic/components/src/components/list-item/list-item.types.ts index 91325d688b..3d9141dcc1 100644 --- a/packages/agentic/components/src/components/list-item/list-item.types.ts +++ b/packages/agentic/components/src/components/list-item/list-item.types.ts @@ -1,4 +1,4 @@ -import type { Pressable, PressableProps, StyleProp, Text, View, ViewStyle } from 'react-native'; +import type { Pressable, StyleProp, Text, View, ViewStyle } from 'react-native'; import type { ThemeState } from '@fluentui-react-native/design'; import type { @@ -7,6 +7,7 @@ import type { OptionalSlot, OwnedRootProps, PressableState, + PropsWithRefOf, Slot, } from '@fluentui-react-native/framework-base'; @@ -45,7 +46,7 @@ export type ListItemStateProps = { selected?: boolean; }; -export type ListItemExposedPressableProps = OwnedRootProps; +export type ListItemExposedPressableProps = OwnedRootProps>; export type ListItemProps = ListItemStateProps & ComponentProps; diff --git a/packages/agentic/components/src/components/list-item/useListItem.ts b/packages/agentic/components/src/components/list-item/useListItem.ts index 35b57f31e1..75e0cdbf1b 100644 --- a/packages/agentic/components/src/components/list-item/useListItem.ts +++ b/packages/agentic/components/src/components/list-item/useListItem.ts @@ -65,6 +65,7 @@ export function useListItem_unstable(props: ListItemProps): ListItemState { content: contentProp = 'List item', disabled = false, icon: iconProp, + ref: rootRef, secondaryContent: secondaryContentProp, secondaryContentPosition = 'right', selected = false, @@ -94,7 +95,7 @@ export function useListItem_unstable(props: ListItemProps): ListItemState { focusable: rest.focusable ?? !disabled, }); - const root = useSlot(Pressable, pressableProps); + const root = useSlot(Pressable, { ...pressableProps, ref: rootRef }); const content = useSlot(Text, contentProp); const contentHidden = useSlot(Text, contentProp); const secondaryContent = useOptionalSlot(Text, secondaryContentProp); diff --git a/packages/agentic/components/src/components/listbox-item/listbox-item.types.ts b/packages/agentic/components/src/components/listbox-item/listbox-item.types.ts index 21efbe2056..6145c146a8 100644 --- a/packages/agentic/components/src/components/listbox-item/listbox-item.types.ts +++ b/packages/agentic/components/src/components/listbox-item/listbox-item.types.ts @@ -1,4 +1,4 @@ -import type { Pressable, PressableProps, Text, View, ViewStyle } from 'react-native'; +import type { Pressable, Text, View, ViewStyle } from 'react-native'; import type { StyleProp } from 'react-native'; import type { @@ -7,6 +7,7 @@ import type { OptionalSlot, OwnedRootProps, PressableState, + PropsWithRefOf, Slot, } from '@fluentui-react-native/framework-base'; import type { ThemeState } from '@fluentui-react-native/design'; @@ -51,7 +52,7 @@ export type ListboxItemStateProps = { selected?: boolean; }; -export type ListboxItemRootProps = OwnedRootProps; +export type ListboxItemRootProps = OwnedRootProps>; export type ListboxItemProps = ListboxItemStateProps & ComponentProps; diff --git a/packages/agentic/components/src/components/listbox-item/useListboxItem.ts b/packages/agentic/components/src/components/listbox-item/useListboxItem.ts index f222cd8d56..6d9921708e 100644 --- a/packages/agentic/components/src/components/listbox-item/useListboxItem.ts +++ b/packages/agentic/components/src/components/listbox-item/useListboxItem.ts @@ -21,6 +21,7 @@ export function useListboxItem_unstable(props: ListboxItemProps): ListboxItemSta icon: iconProp, loading = false, multiselect = false, + ref: rootRef, secondaryContent: secondaryContentProp, secondaryContentPosition = 'right', selected = false, @@ -70,7 +71,7 @@ export function useListboxItem_unstable(props: ListboxItemProps): ListboxItemSta const { onBlur, onFocus, onHoverIn, onHoverOut, onLongPress, onPress, onPressIn, onPressOut, ...headerRest } = rootProps; const headerProps: ViewProps = headerRest as unknown as ViewProps; - const root = useSlot(Pressable, rootProps); + const root = useSlot(Pressable, { ...rootProps, ref: rootRef }); const header = useSlot(View, headerProps); const chevronIndicator = useOptionalSlot(Icon, chevron ? { fontSource: semanticIconSources.chevron } : null); const checkmarkIndicator = useOptionalSlot(Icon, checkmark && selected ? { fontSource: semanticIconSources.checkmark } : null); diff --git a/packages/agentic/components/src/components/menu-item/menu-item.types.ts b/packages/agentic/components/src/components/menu-item/menu-item.types.ts index fa8aa5c9d9..6646fdaa8c 100644 --- a/packages/agentic/components/src/components/menu-item/menu-item.types.ts +++ b/packages/agentic/components/src/components/menu-item/menu-item.types.ts @@ -1,5 +1,12 @@ -import type { Pressable, PressableProps, StyleProp, TextStyle, View, ViewStyle } from 'react-native'; -import type { ComponentProps, ComponentState, OptionalSlot, OwnedRootProps, Slot } from '@fluentui-react-native/framework-base'; +import type { Pressable, StyleProp, TextStyle, View, ViewStyle } from 'react-native'; +import type { + ComponentProps, + ComponentState, + OptionalSlot, + OwnedRootProps, + PropsWithRefOf, + Slot, +} from '@fluentui-react-native/framework-base'; import type { ThemeState } from '@fluentui-react-native/design'; import type { FocusVisualProps } from '../../primitives/focus-visual/focus-visual.types'; @@ -35,7 +42,7 @@ export type MenuItemSlots = { multiselectCheckbox: OptionalSlot; }; -export type MenuItemExposedPressableProps = OwnedRootProps; +export type MenuItemExposedPressableProps = OwnedRootProps>; export type MenuItemProps = MenuItemStateProps & { content?: string | null; diff --git a/packages/agentic/components/src/components/menu-item/useMenuItem.ts b/packages/agentic/components/src/components/menu-item/useMenuItem.ts index c5fb8d7a19..3a9f22f6f5 100644 --- a/packages/agentic/components/src/components/menu-item/useMenuItem.ts +++ b/packages/agentic/components/src/components/menu-item/useMenuItem.ts @@ -33,6 +33,7 @@ export function useMenuItem_unstable(props: MenuItemProps): MenuItemState { icon: iconProp, selectedIcon: selectedIconProp, multiselectCheckbox: multiselectCheckboxProp, + ref: rootRef, style: userStyle, ...rest } = props; @@ -68,7 +69,7 @@ export function useMenuItem_unstable(props: MenuItemProps): MenuItemState { onPress: isInteractive ? rest.onPress : undefined, }); - const root = useSlot(Pressable, pressableProps); + const root = useSlot(Pressable, { ...pressableProps, ref: rootRef }); const icon = useOptionalSlot(Icon, iconProp, { defaultProps: defaultRegularIcon, renderByDefault: true }); const selectedIcon = useOptionalSlot(Icon, selectedIconProp, { defaultProps: defaultSelectedIcon, renderByDefault: selected }); const avatar = useOptionalSlot(View, avatarProp); diff --git a/packages/agentic/components/src/components/progress-bar/progress-bar.types.ts b/packages/agentic/components/src/components/progress-bar/progress-bar.types.ts index d724834e69..d4a8f87274 100644 --- a/packages/agentic/components/src/components/progress-bar/progress-bar.types.ts +++ b/packages/agentic/components/src/components/progress-bar/progress-bar.types.ts @@ -1,6 +1,13 @@ -import type { Animated, AccessibilityState, AccessibilityValue, ColorValue, StyleProp, View, ViewProps, ViewStyle } from 'react-native'; - -import type { ComponentProps, ComponentState, OptionalSlot, OwnedRootProps, Slot } from '@fluentui-react-native/framework-base'; +import type { Animated, AccessibilityState, AccessibilityValue, ColorValue, StyleProp, View, ViewStyle } from 'react-native'; + +import type { + ComponentProps, + ComponentState, + OptionalSlot, + OwnedRootProps, + PropsWithRefOf, + Slot, +} from '@fluentui-react-native/framework-base'; import type { ThemeState } from '@fluentui-react-native/design'; import type { Icon } from '../../primitives/icon/icon'; @@ -29,7 +36,7 @@ export type ProgressBarStateProps = { showValidationIcon?: boolean; }; -export type ProgressBarRootProps = OwnedRootProps; +export type ProgressBarRootProps = OwnedRootProps>; export type ProgressBarProps = ProgressBarStateProps & ComponentProps; diff --git a/packages/agentic/components/src/components/radio/radio.types.ts b/packages/agentic/components/src/components/radio/radio.types.ts index 81ba62a2e6..1fe8cd7578 100644 --- a/packages/agentic/components/src/components/radio/radio.types.ts +++ b/packages/agentic/components/src/components/radio/radio.types.ts @@ -1,7 +1,14 @@ -import type { Pressable, PressableProps, StyleProp, TextStyle, ViewStyle } from 'react-native'; +import type { Pressable, StyleProp, TextStyle, ViewStyle } from 'react-native'; import type { ThemeState } from '@fluentui-react-native/design'; -import type { ComponentProps, ComponentState, OwnedRootProps, PressableState, Slot } from '@fluentui-react-native/framework-base'; +import type { + ComponentProps, + ComponentState, + OwnedRootProps, + PressableState, + PropsWithRefOf, + Slot, +} from '@fluentui-react-native/framework-base'; import type { FocusVisualProps } from '../../primitives/focus-visual/focus-visual.types'; export type RadioSlots = { @@ -35,7 +42,7 @@ export type RadioStateProps = { selected?: boolean; }; -export type RadioExposedPressableProps = OwnedRootProps & { +export type RadioExposedPressableProps = OwnedRootProps> & { children?: never; }; diff --git a/packages/agentic/components/src/components/radio/useRadio.ts b/packages/agentic/components/src/components/radio/useRadio.ts index adc8d5c038..916d4ca24c 100644 --- a/packages/agentic/components/src/components/radio/useRadio.ts +++ b/packages/agentic/components/src/components/radio/useRadio.ts @@ -15,6 +15,7 @@ export function useRadio_unstable(props: RadioProps): RadioState { accessibilityState, disabled = false, label = 'Label', + ref: rootRef, secondaryText = 'Description', selected = false, showSecondaryText = false, @@ -37,7 +38,7 @@ export function useRadio_unstable(props: RadioProps): RadioState { disabled, focusable: rest.focusable ?? !disabled, }); - const root = useSlot(Pressable, pressableProps); + const root = useSlot(Pressable, { ...pressableProps, ref: rootRef }); return { indicatorDotStyle: [], diff --git a/packages/agentic/components/src/components/skeleton/skeleton.types.ts b/packages/agentic/components/src/components/skeleton/skeleton.types.ts index b836ebd7ad..e41c254891 100644 --- a/packages/agentic/components/src/components/skeleton/skeleton.types.ts +++ b/packages/agentic/components/src/components/skeleton/skeleton.types.ts @@ -1,7 +1,7 @@ -import type { LayoutChangeEvent, LayoutRectangle, ViewProps, ViewStyle } from 'react-native'; +import type { LayoutChangeEvent, LayoutRectangle, ViewStyle } from 'react-native'; import type { Animated } from 'react-native'; -import type { ComponentProps, ComponentState, Slot } from '@fluentui-react-native/framework-base'; +import type { ComponentProps, ComponentState, OwnedRootProps, PropsWithRefOf, Slot } from '@fluentui-react-native/framework-base'; import type { ThemeState } from '@fluentui-react-native/design'; import type { StyleProp } from 'react-native'; import type { SlotComponent } from '@fluentui-react-native/framework-base'; @@ -11,7 +11,9 @@ export type SkeletonSlots = { root: Slot; }; -export type SkeletonProps = ComponentProps>; +export type SkeletonRootProps = OwnedRootProps>; + +export type SkeletonProps = ComponentProps; export type SkeletonState = ComponentState & ThemeState & { @@ -26,4 +28,4 @@ export type SkeletonState = ComponentState & progress: Animated.Value; }; -export type SkeletonRootSlot = SlotComponent>; +export type SkeletonRootSlot = SlotComponent; diff --git a/packages/agentic/components/src/components/spinner/spinner.types.ts b/packages/agentic/components/src/components/spinner/spinner.types.ts index 58460b7704..7e37524dfd 100644 --- a/packages/agentic/components/src/components/spinner/spinner.types.ts +++ b/packages/agentic/components/src/components/spinner/spinner.types.ts @@ -1,8 +1,8 @@ -import type { Animated, StyleProp, ViewProps, ViewStyle } from 'react-native'; +import type { Animated, StyleProp, ViewStyle } from 'react-native'; import type { View } from 'react-native'; import type { Svg } from 'react-native-svg'; -import type { ComponentProps, ComponentState, OwnedRootProps, Slot } from '@fluentui-react-native/framework-base'; +import type { ComponentProps, ComponentState, OwnedRootProps, PropsWithRefOf, Slot } from '@fluentui-react-native/framework-base'; import type { ThemeState } from '@fluentui-react-native/design'; export type SpinnerSize = 'x-tiny' | 'tiny' | 'x-small' | 'small' | 'medium' | 'large' | 'x-large' | 'huge'; @@ -19,7 +19,7 @@ export type SpinnerStateProps = { size?: SpinnerSize; }; -export type SpinnerExposedViewProps = OwnedRootProps; +export type SpinnerExposedViewProps = OwnedRootProps, 'accessibilityRole' | 'focusable'>; export type SpinnerProps = SpinnerStateProps & ComponentProps; diff --git a/packages/agentic/components/src/components/switch/switch.types.ts b/packages/agentic/components/src/components/switch/switch.types.ts index 894f00396a..44bec9aa14 100644 --- a/packages/agentic/components/src/components/switch/switch.types.ts +++ b/packages/agentic/components/src/components/switch/switch.types.ts @@ -1,4 +1,4 @@ -import type { Pressable, PressableProps, StyleProp, Text, View, ViewStyle } from 'react-native'; +import type { Pressable, StyleProp, Text, View, ViewStyle } from 'react-native'; import type { ComponentProps, @@ -6,6 +6,7 @@ import type { OptionalSlot, OwnedRootProps, PressableState, + PropsWithRefOf, Slot, } from '@fluentui-react-native/framework-base'; import type { ThemeState } from '@fluentui-react-native/design'; @@ -39,7 +40,7 @@ export type SwitchStateProps = { onChange?: (checked: boolean) => void; }; -export type SwitchExposedPressableProps = OwnedRootProps; +export type SwitchExposedPressableProps = OwnedRootProps>; export type SwitchProps = SwitchStateProps & ComponentProps; diff --git a/packages/agentic/components/src/components/switch/useSwitch.ts b/packages/agentic/components/src/components/switch/useSwitch.ts index 3bfd7c31d1..3101376992 100644 --- a/packages/agentic/components/src/components/switch/useSwitch.ts +++ b/packages/agentic/components/src/components/switch/useSwitch.ts @@ -38,6 +38,7 @@ export function useSwitch_unstable(props: SwitchProps): SwitchState { layout = 'horizontal', onChange, onPress, + ref: rootRef, thumb: thumbProp, style: userStyle, track: trackProp, @@ -137,6 +138,7 @@ export function useSwitch_unstable(props: SwitchProps): SwitchState { const layoutContainer = useSlot(View, { testID: 'switch-layout-container' }); const root = useSlot(Pressable, { ...pressableProps, + ref: rootRef, onPress: handlePress, onKeyUp: handleKeyUp, }); diff --git a/packages/agentic/components/src/components/tab/renderTab.tsx b/packages/agentic/components/src/components/tab/renderTab.tsx index 5aebb42714..383eef1224 100644 --- a/packages/agentic/components/src/components/tab/renderTab.tsx +++ b/packages/agentic/components/src/components/tab/renderTab.tsx @@ -13,7 +13,7 @@ export function renderTab_unstable(state: TabState) { const contentElement = Content && ContentHidden ? } visible={} /> : null; return ( - + {ActiveIcon && } {contentElement} diff --git a/packages/agentic/components/src/components/tab/tab.test.tsx b/packages/agentic/components/src/components/tab/tab.test.tsx index d7f99cf96b..51dbe19932 100644 --- a/packages/agentic/components/src/components/tab/tab.test.tsx +++ b/packages/agentic/components/src/components/tab/tab.test.tsx @@ -1,6 +1,7 @@ /** @jsxImportSource @fluentui-react-native/framework-base */ +import * as React from 'react'; import { StyleSheet } from 'react-native'; -import type { ViewStyle } from 'react-native'; +import type { Pressable, ViewStyle } from 'react-native'; import { fireEvent, render } from '@testing-library/react-native'; import type { RenderResult } from '@testing-library/react-native'; @@ -9,6 +10,8 @@ import { defaultFlexTokens } from '@fluentui-react-native/design/testing'; import { Tab } from './tab'; import { TabList } from '../tablist/tablist'; +import { TabListContext } from '../tablist/TabListContext'; +import type { TabListContextValue } from '../tablist/TabListContext'; function renderTab(props: React.ComponentProps): Promise { return render(); @@ -23,6 +26,46 @@ function getRootStyle(component: RenderResult): ViewStyle { } describe('Tab', () => { + it('keeps its internal focus ref when the consumer omits ref', async () => { + let registeredRef: React.RefObject | null> | undefined; + const contextValue: TabListContextValue = { + activeValue: 'files', + disabled: false, + getPosition: () => 1, + isTabDisabled: () => false, + onTabFocus: jest.fn(), + onTabKeyDown: jest.fn(), + onTabPress: jest.fn(), + orientation: 'horizontal', + registerTab: (_value, ref) => { + registeredRef = ref; + return () => undefined; + }, + selectedValue: 'files', + setSize: 1, + }; + + await render( + + + , + ); + + expect(registeredRef?.current).not.toBeNull(); + }); + + it('composes its ref prop with the internal TabList focus ref', async () => { + const ref = React.createRef>(); + + await render( + + + , + ); + + expect(ref.current).not.toBeNull(); + }); + it('renders selection without changing it on press', async () => { const onPress = jest.fn(); const component = await renderTab({ controls: 'files-panel', content: 'Files', onPress, selected: false }); diff --git a/packages/agentic/components/src/components/tab/tab.types.ts b/packages/agentic/components/src/components/tab/tab.types.ts index 7f75e7168a..f374db691b 100644 --- a/packages/agentic/components/src/components/tab/tab.types.ts +++ b/packages/agentic/components/src/components/tab/tab.types.ts @@ -1,4 +1,5 @@ -import type { Pressable, PressableProps, StyleProp, Text, ViewStyle } from 'react-native'; +import type * as React from 'react'; +import type { Pressable, StyleProp, Text, ViewStyle } from 'react-native'; import type { ComponentProps, @@ -6,6 +7,7 @@ import type { OptionalSlot, OwnedRootProps, PressableState, + PropsWithRefOf, Slot, SlotProp, } from '@fluentui-react-native/framework-base'; @@ -54,7 +56,7 @@ export type TabStateProps = { }; export type TabExposedPressableProps = OwnedRootProps< - PressableProps & { + PropsWithRefOf & { accessibilityPosInSet?: number; accessibilitySetSize?: number; onKeyDown?: (event: TabKeyEvent) => void; @@ -80,5 +82,6 @@ export type TabState = ComponentState & PressableState & { focusVisualProps?: FocusVisualProps; iconOnly: boolean; + tabRef: React.RefObject | null>; userStyle?: StyleProp; }; diff --git a/packages/agentic/components/src/components/tab/useTab.ts b/packages/agentic/components/src/components/tab/useTab.ts index c85cc87b8c..2c977b9eda 100644 --- a/packages/agentic/components/src/components/tab/useTab.ts +++ b/packages/agentic/components/src/components/tab/useTab.ts @@ -1,8 +1,13 @@ import * as React from 'react'; import { Pressable, Text } from 'react-native'; -import type { PressableProps } from 'react-native'; -import { useAccessibilityLabelWarning, usePressableState, useOptionalSlot, useSlot } from '@fluentui-react-native/framework-base'; +import { + type PropsWithRefOf, + useAccessibilityLabelWarning, + usePressableState, + useOptionalSlot, + useSlot, +} from '@fluentui-react-native/framework-base'; import { useThemeState } from '@fluentui-react-native/design'; import type { TabProps, TabState } from './tab.types'; @@ -21,6 +26,7 @@ export function useTab_unstable(props: TabProps): TabState { disabled = false, icon: iconProp, layout = 'iconAndText', + ref: rootRef, selected = false, selectedIcon: selectedIconProp, style: userStyle, @@ -77,8 +83,8 @@ export function useTab_unstable(props: TabProps): TabState { const root = useSlot(Pressable, { ...pressableProps, accessibilityControls: controls, - ref: tabRef, - } as PressableProps & React.RefAttributes> & { accessibilityControls: string }); + ref: rootRef, + } as PropsWithRefOf & { accessibilityControls: string }); const icon = useOptionalSlot(Icon, iconProp); const selectedIcon = useOptionalSlot(Icon, selectedIconProp); const contentSlotProp = iconOnly ? null : (contentProp ?? 'Tab'); @@ -95,6 +101,7 @@ export function useTab_unstable(props: TabProps): TabState { layout, controls, selected: listSelected, + tabRef, value, iconOnly, userStyle, diff --git a/packages/agentic/components/src/components/tablist/tablist.types.ts b/packages/agentic/components/src/components/tablist/tablist.types.ts index b3d78c6b12..ec9a26d6c0 100644 --- a/packages/agentic/components/src/components/tablist/tablist.types.ts +++ b/packages/agentic/components/src/components/tablist/tablist.types.ts @@ -1,7 +1,7 @@ import type * as React from 'react'; import type { View, ViewProps } from 'react-native'; -import type { ComponentProps, ComponentState, OwnedRootProps, Slot } from '@fluentui-react-native/framework-base'; +import type { ComponentProps, ComponentState, OwnedRootProps, PropsWithRefOf, Slot } from '@fluentui-react-native/framework-base'; import type { ThemeState } from '@fluentui-react-native/design'; export type TabListOrientation = 'horizontal' | 'vertical'; @@ -58,15 +58,19 @@ export type TabListStateProps = { selectionFollowsFocus?: boolean; }; -export type TabListExposedViewProps = OwnedRootProps; +export type TabListExposedViewProps = OwnedRootProps< + PropsWithRefOf, + 'accessibilityRole' | 'accessibilityState' | 'accessible' | 'focusable' +>; export type TabListSlots = { root: Slot; }; -export type TabListProps = ComponentProps & { - children: React.ReactNode; -}; +export type TabListProps = TabListStateProps & + ComponentProps & { + children: React.ReactNode; + }; export type TabListState = ComponentState & ThemeState & { diff --git a/packages/agentic/components/src/components/tag/tag.types.ts b/packages/agentic/components/src/components/tag/tag.types.ts index 85326d80f3..828e72ac72 100644 --- a/packages/agentic/components/src/components/tag/tag.types.ts +++ b/packages/agentic/components/src/components/tag/tag.types.ts @@ -1,10 +1,11 @@ -import type { Pressable, PressableProps, StyleProp, Text, ViewStyle } from 'react-native'; +import type { Pressable, StyleProp, Text, ViewStyle } from 'react-native'; import type { ComponentProps, ComponentState, OptionalSlot, OwnedRootProps, PressableState, + PropsWithRefOf, Slot, } from '@fluentui-react-native/framework-base'; import type { ThemeState } from '@fluentui-react-native/design'; @@ -33,7 +34,7 @@ export type TagStateProps = { dismiss?: boolean; }; -export type TagRootProps = OwnedRootProps; +export type TagRootProps = OwnedRootProps>; export type TagProps = TagStateProps & ComponentProps; diff --git a/packages/agentic/components/src/components/tag/useTag.ts b/packages/agentic/components/src/components/tag/useTag.ts index 86b29f58da..8c24e00bf1 100644 --- a/packages/agentic/components/src/components/tag/useTag.ts +++ b/packages/agentic/components/src/components/tag/useTag.ts @@ -21,6 +21,7 @@ export function useTag_unstable(props: TagProps): TagState { disabled = false, layout = 'iconAndText', leadingIcon: leadingIconProp, + ref: rootRef, shape = 'rounded', size = 'medium', style: userStyle, @@ -59,7 +60,7 @@ export function useTag_unstable(props: TagProps): TagState { focusable: rest.focusable ?? !disabled, }); - const root = useSlot(Pressable, pressableProps); + const root = useSlot(Pressable, { ...pressableProps, ref: rootRef }); const content = useOptionalSlot(Text, iconOnly ? null : contentProp, { defaultProps: { children: 'Tag text' }, renderByDefault: true, diff --git a/packages/agentic/components/src/primitives/AGENTS.md b/packages/agentic/components/src/primitives/AGENTS.md index dbbc144829..1ec174b32e 100644 --- a/packages/agentic/components/src/primitives/AGENTS.md +++ b/packages/agentic/components/src/primitives/AGENTS.md @@ -14,6 +14,9 @@ the canonical implementation. defaults. - Define the smallest acceptance contract needed for `SlotProp` consumption. - Prefer a hook-free `directComponent` when the primitive only selects an inner component or transforms props. +- For a primitive with one stable native root type, include `PropsWithRefOf` in its public props and pass + the React 19.1.4 `ref` prop to that root without `forwardRef`. A primitive whose renderer does not have one stable + imperative instance type must omit `ref` and document that exception in its `CONTRACT.md`. - Forward accessibility and test props without replacing consumer values. - Keep alternative source or renderer props mutually exclusive. - Add compile-time `SlotProp` coverage and runtime coverage for every renderer branch. diff --git a/packages/agentic/components/src/primitives/checkbox-indicator/checkbox-indicator.test.tsx b/packages/agentic/components/src/primitives/checkbox-indicator/checkbox-indicator.test.tsx index 4e947fe277..5822dfc812 100644 --- a/packages/agentic/components/src/primitives/checkbox-indicator/checkbox-indicator.test.tsx +++ b/packages/agentic/components/src/primitives/checkbox-indicator/checkbox-indicator.test.tsx @@ -1,9 +1,19 @@ /** @jsxImportSource @fluentui-react-native/framework-base */ +import * as React from 'react'; +import type { View } from 'react-native'; import { render } from '@testing-library/react-native'; import { CheckboxIndicator } from './checkbox-indicator'; describe('CheckboxIndicator', () => { + it('forwards its ref prop to the native root', async () => { + const ref = React.createRef>(); + + await render(); + + expect(ref.current).not.toBeNull(); + }); + it('renders checked and indeterminate glyphs as decorative content', async () => { const checked = await render(); const indicator = checked.getByTestId('indicator'); diff --git a/packages/agentic/components/src/primitives/checkbox-indicator/checkbox-indicator.types.ts b/packages/agentic/components/src/primitives/checkbox-indicator/checkbox-indicator.types.ts index 8ab8e0f34e..1c3900413b 100644 --- a/packages/agentic/components/src/primitives/checkbox-indicator/checkbox-indicator.types.ts +++ b/packages/agentic/components/src/primitives/checkbox-indicator/checkbox-indicator.types.ts @@ -1,11 +1,11 @@ -import type { ColorValue, ViewProps } from 'react-native'; +import type { ColorValue, View } from 'react-native'; -import type { OwnedRootProps } from '@fluentui-react-native/framework-base'; +import type { OwnedRootProps, PropsWithRefOf } from '@fluentui-react-native/framework-base'; import type { FontIconSource } from '../icon/icon.types'; export type CheckboxIndicatorStatus = 'unchecked' | 'checked' | 'indeterminate'; -export type CheckboxIndicatorProps = OwnedRootProps & { +export type CheckboxIndicatorProps = OwnedRootProps, 'accessible'> & { checkedIconSource?: FontIconSource; iconColor?: ColorValue; iconSize?: number; diff --git a/packages/agentic/components/src/primitives/compound-item-layout/compound-item-layout.types.ts b/packages/agentic/components/src/primitives/compound-item-layout/compound-item-layout.types.ts index 637b12dc90..ef204e4ade 100644 --- a/packages/agentic/components/src/primitives/compound-item-layout/compound-item-layout.types.ts +++ b/packages/agentic/components/src/primitives/compound-item-layout/compound-item-layout.types.ts @@ -1,10 +1,10 @@ import type * as React from 'react'; -import type { StyleProp, ViewProps, ViewStyle } from 'react-native'; +import type { StyleProp, View, ViewStyle } from 'react-native'; -import type { OwnedRootProps } from '@fluentui-react-native/framework-base'; +import type { OwnedRootProps, PropsWithRefOf } from '@fluentui-react-native/framework-base'; import type { ItemSecondaryContentPosition } from '../../common/item.types'; -export type CompoundItemLayoutProps = OwnedRootProps & { +export type CompoundItemLayoutProps = OwnedRootProps> & { contentStyle?: StyleProp; leading?: React.ReactNode; leadingStyle?: StyleProp; diff --git a/packages/agentic/components/src/primitives/focus-visual/focus-visual.types.ts b/packages/agentic/components/src/primitives/focus-visual/focus-visual.types.ts index 346d7842df..28cc347610 100644 --- a/packages/agentic/components/src/primitives/focus-visual/focus-visual.types.ts +++ b/packages/agentic/components/src/primitives/focus-visual/focus-visual.types.ts @@ -1,4 +1,5 @@ -import type { ColorValue, StyleProp, ViewProps, ViewStyle } from 'react-native'; +import type { ColorValue, StyleProp, View, ViewStyle } from 'react-native'; +import type { PropsWithRefOf } from '@fluentui-react-native/framework-base'; type FocusVisualOwnedProps = | 'accessibilityElementsHidden' @@ -9,7 +10,7 @@ type FocusVisualOwnedProps = | 'importantForAccessibility' | 'pointerEvents'; -export type FocusVisualRingProps = Omit; +export type FocusVisualRingProps = Omit, FocusVisualOwnedProps>; export type FocusVisualProps = FocusVisualRingProps & { /** diff --git a/packages/agentic/components/src/primitives/icon/CONTRACT.md b/packages/agentic/components/src/primitives/icon/CONTRACT.md index 67e8c796ec..9ed83787a0 100644 --- a/packages/agentic/components/src/primitives/icon/CONTRACT.md +++ b/packages/agentic/components/src/primitives/icon/CONTRACT.md @@ -8,4 +8,6 @@ component boundary. renderer. - The default accessibility role is `image` unless the consumer supplies one. - A missing source renders no native element. +- The primitive does not expose a component ref because its image, text, custom + SVG, and empty branches do not share one stable imperative instance type. - The primitive does not select glyphs, token values, or product styling. diff --git a/packages/agentic/components/src/primitives/icon/icon.types.ts b/packages/agentic/components/src/primitives/icon/icon.types.ts index 3b7b489174..7dea10dacd 100644 --- a/packages/agentic/components/src/primitives/icon/icon.types.ts +++ b/packages/agentic/components/src/primitives/icon/icon.types.ts @@ -54,4 +54,4 @@ type EmptyIconProps = { svgSource?: never; }; -export type IconProps = IconElementProps & (ImageIconProps | FontIconProps | SvgIconProps | EmptyIconProps); +export type IconProps = IconElementProps & { ref?: never } & (ImageIconProps | FontIconProps | SvgIconProps | EmptyIconProps); diff --git a/packages/agentic/components/src/primitives/layout-stable-text/layout-stable-text.types.ts b/packages/agentic/components/src/primitives/layout-stable-text/layout-stable-text.types.ts index f4cb118d22..9de2866ce2 100644 --- a/packages/agentic/components/src/primitives/layout-stable-text/layout-stable-text.types.ts +++ b/packages/agentic/components/src/primitives/layout-stable-text/layout-stable-text.types.ts @@ -1,9 +1,9 @@ import type * as React from 'react'; -import type { TextProps, ViewProps } from 'react-native'; +import type { TextProps, View } from 'react-native'; -import type { OwnedRootProps } from '@fluentui-react-native/framework-base'; +import type { OwnedRootProps, PropsWithRefOf } from '@fluentui-react-native/framework-base'; -export type LayoutStableTextProps = OwnedRootProps & { +export type LayoutStableTextProps = OwnedRootProps, 'accessible'> & { /** * The hidden text element whose metrics reserve enough space for every visual state. */ diff --git a/packages/agentic/components/src/refs.types.test.tsx b/packages/agentic/components/src/refs.types.test.tsx new file mode 100644 index 0000000000..aa9e87eacc --- /dev/null +++ b/packages/agentic/components/src/refs.types.test.tsx @@ -0,0 +1,85 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import * as React from 'react'; +import type { Image, Pressable, View } from 'react-native'; +import { Text } from 'react-native'; + +import { + Accordion, + Avatar, + Badge, + Button, + Card, + Checkbox, + Divider, + Input, + ListItem, + ListboxItem, + MenuItem, + ProgressBar, + Radio, + Skeleton, + Spinner, + Switch, + Tab, + TabList, + Tag, +} from './index'; +import { CheckboxIndicator, CompoundItemLayout, FocusVisual, LayoutStableText } from './primitives/index'; +import type { IconProps } from './primitives/index'; + +const pressableRef = React.createRef>(); +const viewRef = React.createRef>(); +const imageRef = React.createRef>(); + +function ComponentsWithNativeRootRefs() { + return ( + <> + + + +