diff --git a/.changeset/public-primitives-subpath.md b/.changeset/public-primitives-subpath.md new file mode 100644 index 00000000000..e5c2569d73c --- /dev/null +++ b/.changeset/public-primitives-subpath.md @@ -0,0 +1,6 @@ +--- +"@fluentui-react-native/components": minor +--- + +Move public primitive exports from the package root to the `./primitives` +subpath and document their contracts. diff --git a/packages/agentic/components/AGENTS.md b/packages/agentic/components/AGENTS.md index 4a3f036a6ac..a6d9ed1d5a3 100644 --- a/packages/agentic/components/AGENTS.md +++ b/packages/agentic/components/AGENTS.md @@ -20,7 +20,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. -- Export components and public types explicitly from `src/index.ts`; never use wildcard exports. +- 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. - Use package scripts for format, lint, build, tests, and snapshots. - Do not copy web-only APIs, CSS behavior, or DOM assumptions into React Native. diff --git a/packages/agentic/components/package.json b/packages/agentic/components/package.json index a5ebc7e0459..a1c36580570 100644 --- a/packages/agentic/components/package.json +++ b/packages/agentic/components/package.json @@ -19,6 +19,12 @@ "react-native": "./src/index.ts", "import": "./lib/index.js", "default": "./src/index.ts" + }, + "./primitives": { + "types": "./lib/primitives/index.d.ts", + "react-native": "./src/primitives/index.ts", + "import": "./lib/primitives/index.js", + "default": "./src/primitives/index.ts" } }, "scripts": { diff --git a/packages/agentic/components/src/AGENTS.md b/packages/agentic/components/src/AGENTS.md index 274153d966c..14f6f6506af 100644 --- a/packages/agentic/components/src/AGENTS.md +++ b/packages/agentic/components/src/AGENTS.md @@ -19,14 +19,17 @@ the change crosses component boundaries. - Generalizable non-styling hooks belong in `framework-base/src/hooks`. - Styling helpers belong in `agentic/design/src/styling`. - Component-library-specific non-public types, constants, and helpers belong in `src/common`. -- Primitives must remain unstyled and should only be extracted for repeated behavioral or structural contracts. +- Primitives are public from `@fluentui-react-native/components/primitives`, must remain unstyled, and require a colocated + `CONTRACT.md`. Extract one only for a repeated behavioral or structural contract that is useful to consumers; keep + package-private helpers in `src/common`. ## Optimization principles - Audit dependency direction before introducing shared code. - Look for repeated types, constants, routines, and subtrees across components. - Validate extraction payoff before creating another layer of indirection. -- Preserve public component-qualified APIs and explicit exports. +- Preserve public component-qualified APIs and explicit exports. Higher-order components belong to the root entry point; + primitives belong to the `./primitives` entry point. - Keep local component fixes local; do not widen a tiny edit into a whole-package audit unless repetition or extraction is part of the work. - Do not extract one-off logic, styling choices, or thin wrappers that are clearer in place. diff --git a/packages/agentic/components/src/components/accordion/useAccordionStyles.ts b/packages/agentic/components/src/components/accordion/useAccordionStyles.ts index b828af7357e..20b9dfc29ab 100644 --- a/packages/agentic/components/src/components/accordion/useAccordionStyles.ts +++ b/packages/agentic/components/src/components/accordion/useAccordionStyles.ts @@ -1,7 +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 { createFocusVisualProps_unstable } from '../../primitives/focus-visual/focus-visual'; import { accordionStyles, @@ -51,7 +51,7 @@ export function useAccordionStyles_unstable(state: AccordionState) { ]; const iconSize = getAccordionIconSize(); - state.focusVisualProps = createFocusVisualProps({ + state.focusVisualProps = createFocusVisualProps_unstable({ borderRadius: headerLayoutStyle.borderRadius, innerColor: state.tokens.color.strokeFocusInner, innerWidth: state.tokens.strokeWidth.thin, diff --git a/packages/agentic/components/src/components/button/useButtonStyles.ts b/packages/agentic/components/src/components/button/useButtonStyles.ts index 2ed64e2e0e2..4a8e9515198 100644 --- a/packages/agentic/components/src/components/button/useButtonStyles.ts +++ b/packages/agentic/components/src/components/button/useButtonStyles.ts @@ -1,7 +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 { createFocusVisualProps_unstable } from '../../primitives/focus-visual/focus-visual'; import { buttonStyles, getButtonColorStyles, getButtonContentStyle, getButtonIconSize, getButtonRootStyle } from './button.styles'; import type { ButtonState } from './button.types'; @@ -18,7 +18,7 @@ export function useButtonStyles_unstable(state: ButtonState) { const contentStyle: StyleProp = [buttonStyles.content, getButtonContentStyle(state), colors.foreground]; const iconSize = getButtonIconSize(size); - state.focusVisualProps = createFocusVisualProps({ + state.focusVisualProps = createFocusVisualProps_unstable({ borderRadius: rootLayoutStyle.borderRadius, innerColor: state.tokens.color.strokeFocusInner, innerWidth: state.tokens.strokeWidth.thin, diff --git a/packages/agentic/components/src/components/card/useCardStyles.ts b/packages/agentic/components/src/components/card/useCardStyles.ts index 5f4562d961b..7a2de99e361 100644 --- a/packages/agentic/components/src/components/card/useCardStyles.ts +++ b/packages/agentic/components/src/components/card/useCardStyles.ts @@ -1,7 +1,7 @@ import type { StyleProp, ViewStyle } from 'react-native'; import { attachSlotProps } from '@fluentui-react-native/framework-base'; -import { createFocusVisualProps } from '../../primitives/focus-visual/focus-visual'; +import { createFocusVisualProps_unstable } from '../../primitives/focus-visual/focus-visual'; import { cardStyles, getCardNestedBlockStyle, getCardOverlayStyle, getCardRootStyle, getCardSurfaceColors } from './card.styles'; import type { CardState } from './card.types'; @@ -14,7 +14,7 @@ export function useCardStyles_unstable(state: CardState) { const rootStyle: StyleProp = [cardStyles.root, getCardRootStyle(state), colors, state.userStyle]; const overlayStyle = getCardOverlayStyle(state); - state.focusVisualProps = createFocusVisualProps({ + state.focusVisualProps = createFocusVisualProps_unstable({ borderRadius: overlayStyle.borderRadius, innerColor: state.tokens.color.strokeFocusInner, innerWidth: state.tokens.strokeWidth.thin, diff --git a/packages/agentic/components/src/components/checkbox/useCheckboxStyles.ts b/packages/agentic/components/src/components/checkbox/useCheckboxStyles.ts index e9ea80e7280..40d3d7d4adf 100644 --- a/packages/agentic/components/src/components/checkbox/useCheckboxStyles.ts +++ b/packages/agentic/components/src/components/checkbox/useCheckboxStyles.ts @@ -1,7 +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 { createFocusVisualProps_unstable } from '../../primitives/focus-visual/focus-visual'; import { checkboxStyles, @@ -30,7 +30,7 @@ export function useCheckboxStyles_unstable(state: CheckboxState) { const labelStyle: StyleProp = [checkboxStyles.labelText, textThemeStyles.label, labelColors]; const secondaryTextStyle: StyleProp = [checkboxStyles.secondaryText, textThemeStyles.secondaryText, secondaryTextColors]; - state.focusVisualProps = createFocusVisualProps({ + state.focusVisualProps = createFocusVisualProps_unstable({ borderRadius: state.tokens.borderRadius.base300, innerColor: state.tokens.color.strokeFocusInner, innerWidth: state.tokens.strokeWidth.thin, diff --git a/packages/agentic/components/src/components/list-item/useListItemStyles.ts b/packages/agentic/components/src/components/list-item/useListItemStyles.ts index cb0c5a63df5..7a666bca709 100644 --- a/packages/agentic/components/src/components/list-item/useListItemStyles.ts +++ b/packages/agentic/components/src/components/list-item/useListItemStyles.ts @@ -3,7 +3,7 @@ import type { StyleProp, TextStyle, ViewStyle } from 'react-native'; import { attachSlotProps } from '@fluentui-react-native/framework-base'; import { hiddenFromAccessibilityProps } from '../../common/accessibility'; -import { createFocusVisualProps } from '../../primitives/focus-visual/focus-visual'; +import { createFocusVisualProps_unstable } from '../../primitives/focus-visual/focus-visual'; import { getListItemBackgroundStyle, getListItemContentStyle, @@ -55,7 +55,7 @@ export function useListItemStyles_unstable(state: ListItemState) { }, ]; - state.focusVisualProps = createFocusVisualProps({ + state.focusVisualProps = createFocusVisualProps_unstable({ borderRadius: rootSizeStyle.borderRadius, innerColor: state.tokens.color.strokeFocusInner, innerWidth: state.tokens.strokeWidth.thin, diff --git a/packages/agentic/components/src/components/listbox-item/useListboxItemStyles.ts b/packages/agentic/components/src/components/listbox-item/useListboxItemStyles.ts index c043b853f6d..77a6b2ca5d6 100644 --- a/packages/agentic/components/src/components/listbox-item/useListboxItemStyles.ts +++ b/packages/agentic/components/src/components/listbox-item/useListboxItemStyles.ts @@ -1,7 +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 { createFocusVisualProps_unstable } from '../../primitives/focus-visual/focus-visual'; import { getListboxItemAvatarSize, @@ -20,7 +20,7 @@ import type { ListboxItemState } from './listbox-item.types'; export function useListboxItemStyles_unstable(state: ListboxItemState) { const resolvedRootStyle = getListboxItemRootStyle(state); const rootStyle: StyleProp = [listboxItemStyles.root, resolvedRootStyle, state.userStyle]; - state.focusVisualProps = createFocusVisualProps({ + state.focusVisualProps = createFocusVisualProps_unstable({ borderRadius: resolvedRootStyle.borderRadius, innerColor: state.tokens.color.strokeFocusInner, innerWidth: state.tokens.strokeWidth.thin, diff --git a/packages/agentic/components/src/components/menu-item/useMenuItemStyles.ts b/packages/agentic/components/src/components/menu-item/useMenuItemStyles.ts index 8021bfdc126..629a9eb0d33 100644 --- a/packages/agentic/components/src/components/menu-item/useMenuItemStyles.ts +++ b/packages/agentic/components/src/components/menu-item/useMenuItemStyles.ts @@ -3,7 +3,7 @@ import type { StyleProp, ViewStyle } from 'react-native'; import { attachSlotProps } from '@fluentui-react-native/framework-base'; import { hiddenFromAccessibilityProps } from '../../common/accessibility'; -import { createFocusVisualProps } from '../../primitives/focus-visual/focus-visual'; +import { createFocusVisualProps_unstable } from '../../primitives/focus-visual/focus-visual'; import { getMenuItemCheckboxStyle, getMenuItemLeadingStyle, @@ -21,7 +21,7 @@ export function useMenuItemStyles_unstable(state: MenuItemState) { const rootLayoutStyle = getMenuItemRootLayoutStyle(state); const rootStyle: StyleProp = [menuItemStyles.root, rootLayoutStyle, getMenuItemRootStyle(state), state.userStyle]; - state.focusVisualProps = createFocusVisualProps({ + state.focusVisualProps = createFocusVisualProps_unstable({ borderRadius: rootLayoutStyle.borderRadius, innerColor: state.tokens.color.strokeFocusInner, innerWidth: state.tokens.strokeWidth.thin, diff --git a/packages/agentic/components/src/components/radio/useRadioStyles.ts b/packages/agentic/components/src/components/radio/useRadioStyles.ts index ad7071373c6..fc91099f6a4 100644 --- a/packages/agentic/components/src/components/radio/useRadioStyles.ts +++ b/packages/agentic/components/src/components/radio/useRadioStyles.ts @@ -1,6 +1,6 @@ import { attachSlotProps } from '@fluentui-react-native/framework-base'; import type { StyleProp, TextStyle, ViewStyle } from 'react-native'; -import { createFocusVisualProps } from '../../primitives/focus-visual/focus-visual'; +import { createFocusVisualProps_unstable } from '../../primitives/focus-visual/focus-visual'; import { radioStyles, @@ -38,7 +38,7 @@ export function useRadioStyles_unstable(state: RadioState) { getRadioSecondaryTextColorStyle(state), ]; - state.focusVisualProps = createFocusVisualProps({ + state.focusVisualProps = createFocusVisualProps_unstable({ borderRadius: rootLayoutStyle.borderRadius, innerColor: state.tokens.color.strokeFocusInner, innerWidth: state.tokens.strokeWidth.thin, diff --git a/packages/agentic/components/src/components/switch/useSwitchStyles.ts b/packages/agentic/components/src/components/switch/useSwitchStyles.ts index c1fb0c07302..9bd5ffd864e 100644 --- a/packages/agentic/components/src/components/switch/useSwitchStyles.ts +++ b/packages/agentic/components/src/components/switch/useSwitchStyles.ts @@ -1,7 +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 { createFocusVisualProps_unstable } from '../../primitives/focus-visual/focus-visual'; import { getSwitchLabelStyle, @@ -63,7 +63,7 @@ export function useSwitchStyles_unstable(state: SwitchState) { ]; const labelStyle: StyleProp = [switchStyles.label, getSwitchLabelStyle(state)]; - state.focusVisualProps = createFocusVisualProps({ + state.focusVisualProps = createFocusVisualProps_unstable({ borderRadius: rootBaseStyle.borderRadius, innerColor: state.tokens.color.strokeFocusInner, innerWidth: state.tokens.strokeWidth.thin, diff --git a/packages/agentic/components/src/components/tab/useTabStyles.ts b/packages/agentic/components/src/components/tab/useTabStyles.ts index def9c0fe10d..296d28131d1 100644 --- a/packages/agentic/components/src/components/tab/useTabStyles.ts +++ b/packages/agentic/components/src/components/tab/useTabStyles.ts @@ -1,7 +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 { createFocusVisualProps_unstable } from '../../primitives/focus-visual/focus-visual'; import { tabStyles, getTabColorStyles, getTabContentStyle, getTabIconSize, getTabRootStyle } from './tab.styles'; import type { TabState } from './tab.types'; @@ -17,7 +17,7 @@ export function useTabStyles_unstable(state: TabState) { const hiddenContentStyle: StyleProp = [tabStyles.content, getTabContentStyle(state, true), colors.foreground]; const iconSize = getTabIconSize(); - state.focusVisualProps = createFocusVisualProps({ + state.focusVisualProps = createFocusVisualProps_unstable({ borderRadius: rootLayoutStyle.borderRadius, innerColor: state.tokens.color.strokeFocusInner, innerWidth: state.tokens.strokeWidth.thin, diff --git a/packages/agentic/components/src/components/tag/useTagStyles.ts b/packages/agentic/components/src/components/tag/useTagStyles.ts index dba20942e4e..7c41069129d 100644 --- a/packages/agentic/components/src/components/tag/useTagStyles.ts +++ b/packages/agentic/components/src/components/tag/useTagStyles.ts @@ -1,6 +1,6 @@ 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 { createFocusVisualProps_unstable } from '../../primitives/focus-visual/focus-visual'; import { tagStyles, getTagBackgroundStyle, getTagContentStyle, getTagForegroundStyle, getTagIconSize, getTagRootStyle } from './tag.styles'; import type { TagState } from './tag.types'; @@ -14,7 +14,7 @@ export function useTagStyles_unstable(state: TagState) { const contentStyle: StyleProp = [tagStyles.content, getTagContentStyle(state), foreground]; const iconSizes = getTagIconSize(size); - state.focusVisualProps = createFocusVisualProps({ + state.focusVisualProps = createFocusVisualProps_unstable({ borderRadius: rootLayoutStyle.borderRadius, innerColor: state.tokens.color.strokeFocusInner, innerWidth: state.tokens.strokeWidth.thin, diff --git a/packages/agentic/components/src/index.test.ts b/packages/agentic/components/src/index.test.ts index 530065015af..fa389d15c35 100644 --- a/packages/agentic/components/src/index.test.ts +++ b/packages/agentic/components/src/index.test.ts @@ -1,75 +1,82 @@ import * as components from './index'; -const compositionHelpers = [ - components.useAccordion_unstable, - components.useAccordionStyles_unstable, - components.renderAccordion_unstable, - components.useAvatar_unstable, - components.useAvatarStyles_unstable, - components.renderAvatar_unstable, - components.useBadge_unstable, - components.useBadgeStyles_unstable, - components.renderBadge_unstable, - components.useButton_unstable, - components.useButtonStyles_unstable, - components.renderButton_unstable, - components.useCard_unstable, - components.useCardStyles_unstable, - components.renderCard_unstable, - components.useCheckbox_unstable, - components.useCheckboxStyles_unstable, - components.renderCheckbox_unstable, - components.useDivider_unstable, - components.useDividerStyles_unstable, - components.renderDivider_unstable, - components.useInput_unstable, - components.useInputStyles_unstable, - components.renderInput_unstable, - components.useListItem_unstable, - components.useListItemStyles_unstable, - components.renderListItem_unstable, - components.useListboxItem_unstable, - components.useListboxItemStyles_unstable, - components.renderListboxItem_unstable, - components.useMenuItem_unstable, - components.useMenuItemStyles_unstable, - components.renderMenuItem_unstable, - components.useProgressBar_unstable, - components.useProgressBarStyles_unstable, - components.renderProgressBar_unstable, - components.useRadio_unstable, - components.useRadioStyles_unstable, - components.renderRadio_unstable, - components.useSkeleton_unstable, - components.useSkeletonStyles_unstable, - components.renderSkeleton_unstable, - components.useSpinner_unstable, - components.useSpinnerStyles_unstable, - components.renderSpinner_unstable, - components.useSwitch_unstable, - components.useSwitchStyles_unstable, - components.renderSwitch_unstable, - components.useTab_unstable, - components.useTabStyles_unstable, - components.renderTab_unstable, - components.useTag_unstable, - components.useTagStyles_unstable, - components.renderTag_unstable, -] as const; - -describe('component composition exports', () => { - it('exports each state, style, and render helper', () => { - expect(compositionHelpers).toHaveLength(54); - compositionHelpers.forEach((helper) => { - expect(helper).toEqual(expect.any(Function)); - }); - }); - - it('exports shared primitives', () => { - expect(components.CheckboxIndicator).toEqual(expect.any(Function)); - expect(components.CompoundItemLayout).toEqual(expect.any(Function)); - expect(components.FocusVisual).toEqual(expect.any(Function)); - expect(components.createFocusVisualProps).toEqual(expect.any(Function)); - expect(components.LayoutStableText).toEqual(expect.any(Function)); +describe('component exports', () => { + it('exports exactly the higher-order component runtime API', () => { + expect(Object.keys(components).sort()).toEqual( + [ + 'Accordion', + 'Avatar', + 'Badge', + 'Button', + 'Card', + 'Checkbox', + 'Divider', + 'Input', + 'ListItem', + 'ListboxItem', + 'MenuItem', + 'ProgressBar', + 'Radio', + 'Skeleton', + 'Spinner', + 'Switch', + 'Tab', + 'Tag', + 'renderAccordion_unstable', + 'renderAvatar_unstable', + 'renderBadge_unstable', + 'renderButton_unstable', + 'renderCard_unstable', + 'renderCheckbox_unstable', + 'renderDivider_unstable', + 'renderInput_unstable', + 'renderListItem_unstable', + 'renderListboxItem_unstable', + 'renderMenuItem_unstable', + 'renderProgressBar_unstable', + 'renderRadio_unstable', + 'renderSkeleton_unstable', + 'renderSpinner_unstable', + 'renderSwitch_unstable', + 'renderTab_unstable', + 'renderTag_unstable', + 'useAccordionStyles_unstable', + 'useAccordion_unstable', + 'useAvatarStyles_unstable', + 'useAvatar_unstable', + 'useBadgeStyles_unstable', + 'useBadge_unstable', + 'useButtonStyles_unstable', + 'useButton_unstable', + 'useCardStyles_unstable', + 'useCard_unstable', + 'useCheckboxStyles_unstable', + 'useCheckbox_unstable', + 'useDividerStyles_unstable', + 'useDivider_unstable', + 'useInputStyles_unstable', + 'useInput_unstable', + 'useListItemStyles_unstable', + 'useListItem_unstable', + 'useListboxItemStyles_unstable', + 'useListboxItem_unstable', + 'useMenuItemStyles_unstable', + 'useMenuItem_unstable', + 'useProgressBarStyles_unstable', + 'useProgressBar_unstable', + 'useRadioStyles_unstable', + 'useRadio_unstable', + 'useSkeletonStyles_unstable', + 'useSkeleton_unstable', + 'useSpinnerStyles_unstable', + 'useSpinner_unstable', + 'useSwitchStyles_unstable', + 'useSwitch_unstable', + 'useTabStyles_unstable', + 'useTab_unstable', + 'useTagStyles_unstable', + 'useTag_unstable', + ].sort(), + ); }); }); diff --git a/packages/agentic/components/src/index.ts b/packages/agentic/components/src/index.ts index b8aafdd0e48..a0ddba8028c 100644 --- a/packages/agentic/components/src/index.ts +++ b/packages/agentic/components/src/index.ts @@ -153,23 +153,3 @@ export type { TagAppearance, TagLayout, TagProps, TagShape, TagSize, TagSlots, T export { renderTag_unstable } from './components/tag/renderTag'; export { useTagStyles_unstable } from './components/tag/useTagStyles'; export { useTag_unstable } from './components/tag/useTag'; - -export { Icon } from './primitives/icon/icon'; -export type { FontIconSource, IconElementProps, IconProps, SvgIconSource } from './primitives/icon/icon.types'; - -export { CheckboxIndicator } from './primitives/checkbox-indicator/checkbox-indicator'; -export type { CheckboxIndicatorProps, CheckboxIndicatorStatus } from './primitives/checkbox-indicator/checkbox-indicator.types'; - -export { CompoundItemLayout } from './primitives/compound-item-layout/compound-item-layout'; -export type { CompoundItemLayoutProps } from './primitives/compound-item-layout/compound-item-layout.types'; - -export { FocusVisual, createFocusVisualProps } from './primitives/focus-visual/focus-visual'; -export type { - FocusVisualOptions, - FocusVisualProps, - FocusVisualRingProps, - FocusVisualStyles, -} from './primitives/focus-visual/focus-visual.types'; - -export { LayoutStableText } from './primitives/layout-stable-text/layout-stable-text'; -export type { LayoutStableTextProps } from './primitives/layout-stable-text/layout-stable-text.types'; diff --git a/packages/agentic/components/src/primitives/AGENTS.md b/packages/agentic/components/src/primitives/AGENTS.md index 65b23435c31..dbbc1448298 100644 --- a/packages/agentic/components/src/primitives/AGENTS.md +++ b/packages/agentic/components/src/primitives/AGENTS.md @@ -5,6 +5,11 @@ the canonical implementation. ## Non-negotiable invariants +- Primitives are public only from `@fluentui-react-native/components/primitives`; do not re-export them from the package root. +- Give primitive components stable public names. Suffix public composition helpers whose contracts may evolve with + `_unstable`, matching higher-order component pipeline helpers. +- Maintain a colocated `CONTRACT.md` for every public primitive. Derive its test obligations from that contract, its public + types, and each renderer branch rather than creating an upstream-backed `SPEC.md`. - Primitives are unstyled building blocks. Do not read themes, apply design tokens, or choose product appearance defaults. - Define the smallest acceptance contract needed for `SlotProp` consumption. @@ -15,6 +20,8 @@ the canonical implementation. - Demonstrate the primitive in Storybook without adding component-level styling. - `FocusVisual` mounts configured ring Views eagerly, owns accessibility and hit testing, and changes only opacity when focus visibility changes. Keep token selection and component-specific ring geometry in the consuming component. +- Extract a new primitive only when multiple components share a stable behavioral or structural contract and the public + abstraction is smaller than the duplication. Keep one-off helpers and component-specific behavior local. ## Focused references diff --git a/packages/agentic/components/src/primitives/README.md b/packages/agentic/components/src/primitives/README.md new file mode 100644 index 00000000000..a29cba3a592 --- /dev/null +++ b/packages/agentic/components/src/primitives/README.md @@ -0,0 +1,20 @@ +# Public primitives + +Import these unstyled building blocks from +`@fluentui-react-native/components/primitives`. They are not exported from the +package root. + +Primitive component names follow the same stability convention as higher-order +components. Public helpers whose composition contract may change use an +`_unstable` suffix. + +| Primitive | Contract | +| -------------------- | -------------------------------------------------------- | +| `CheckboxIndicator` | [Checkbox indicator](checkbox-indicator/CONTRACT.md) | +| `CompoundItemLayout` | [Compound item layout](compound-item-layout/CONTRACT.md) | +| `FocusVisual` | [Focus visual](focus-visual/CONTRACT.md) | +| `Icon` | [Icon](icon/CONTRACT.md) | +| `LayoutStableText` | [Layout-stable text](layout-stable-text/CONTRACT.md) | + +Each primitive is extracted for a reusable behavioral or structural contract. +Package-specific private helpers belong in `src/common` instead. diff --git a/packages/agentic/components/src/primitives/checkbox-indicator/CONTRACT.md b/packages/agentic/components/src/primitives/checkbox-indicator/CONTRACT.md new file mode 100644 index 00000000000..49a4dfc7303 --- /dev/null +++ b/packages/agentic/components/src/primitives/checkbox-indicator/CONTRACT.md @@ -0,0 +1,10 @@ +# CheckboxIndicator contract + +`CheckboxIndicator` is an unstyled, decorative indicator for checkbox state. + +- `status` selects no glyph, a checkmark, or an indeterminate mark. +- Checked and indeterminate glyph sources are independently replaceable. +- `iconColor` and `iconSize` are forwarded to the rendered `Icon`. +- The root remains inaccessible so the owning checkbox supplies semantics. +- Native root, accessibility, and test props not owned by the primitive are + forwarded. diff --git a/packages/agentic/components/src/primitives/compound-item-layout/CONTRACT.md b/packages/agentic/components/src/primitives/compound-item-layout/CONTRACT.md new file mode 100644 index 00000000000..7a690695c38 --- /dev/null +++ b/packages/agentic/components/src/primitives/compound-item-layout/CONTRACT.md @@ -0,0 +1,10 @@ +# CompoundItemLayout contract + +`CompoundItemLayout` is an unstyled structural layout for compound rows. + +- Primary content is required. +- Leading, secondary, and trailing regions are optional. +- Secondary content may appear beside or under primary content. +- Region styles apply after the primitive's structural styles. +- Native root, accessibility, and test props not owned by the primitive are + forwarded. diff --git a/packages/agentic/components/src/primitives/focus-visual/CONTRACT.md b/packages/agentic/components/src/primitives/focus-visual/CONTRACT.md new file mode 100644 index 00000000000..7a4565fe925 --- /dev/null +++ b/packages/agentic/components/src/primitives/focus-visual/CONTRACT.md @@ -0,0 +1,11 @@ +# FocusVisual contract + +`FocusVisual` is an unstyled, decorative focus-ring structure. + +- The outer ring is always mounted; an optional inner ring enables a dual-ring + visual. +- Visibility changes opacity without mounting or removing configured rings. +- Rings do not participate in hit testing or the accessibility tree. +- Native root and test props not owned by the primitive are forwarded. +- `createFocusVisualProps_unstable` converts ring geometry and colors into + `FocusVisualProps`; its composition contract may change. diff --git a/packages/agentic/components/src/primitives/focus-visual/focus-visual.stories.tsx b/packages/agentic/components/src/primitives/focus-visual/focus-visual.stories.tsx index 1b7548e61d0..bdb9734f20f 100644 --- a/packages/agentic/components/src/primitives/focus-visual/focus-visual.stories.tsx +++ b/packages/agentic/components/src/primitives/focus-visual/focus-visual.stories.tsx @@ -3,7 +3,7 @@ import { StyleSheet, Text, View } from 'react-native'; import type { Meta, StoryObj } from '@storybook/react-native'; -import { FocusVisual, createFocusVisualProps } from './focus-visual'; +import { FocusVisual, createFocusVisualProps_unstable } from './focus-visual'; const meta: Meta = { title: 'Primitives/Focus Visual', @@ -26,7 +26,7 @@ const FocusTarget = ({ dual, testID, visible = true }: { dual?: boolean; testID? Focus target { it('keeps both rings mounted while visibility changes', async () => { - const props = createFocusVisualProps({ + const props = createFocusVisualProps_unstable({ borderRadius: 4, innerColor: 'white', innerWidth: 1, @@ -49,7 +49,7 @@ describe('FocusVisual', () => { it('renders a single ring when no inner ring is requested', async () => { const component = await render( { + it('exports exactly the primitive runtime API', () => { + expect(Object.keys(primitives).sort()).toEqual( + ['CheckboxIndicator', 'CompoundItemLayout', 'FocusVisual', 'Icon', 'LayoutStableText', 'createFocusVisualProps_unstable'].sort(), + ); + }); +}); diff --git a/packages/agentic/components/src/primitives/index.ts b/packages/agentic/components/src/primitives/index.ts new file mode 100644 index 00000000000..4a5924262b7 --- /dev/null +++ b/packages/agentic/components/src/primitives/index.ts @@ -0,0 +1,14 @@ +export { CheckboxIndicator } from './checkbox-indicator/checkbox-indicator'; +export type { CheckboxIndicatorProps, CheckboxIndicatorStatus } from './checkbox-indicator/checkbox-indicator.types'; + +export { CompoundItemLayout } from './compound-item-layout/compound-item-layout'; +export type { CompoundItemLayoutProps } from './compound-item-layout/compound-item-layout.types'; + +export { FocusVisual, createFocusVisualProps_unstable } from './focus-visual/focus-visual'; +export type { FocusVisualOptions, FocusVisualProps, FocusVisualRingProps, FocusVisualStyles } from './focus-visual/focus-visual.types'; + +export { Icon } from './icon/icon'; +export type { FontIconSource, IconElementProps, IconProps, SvgIconSource } from './icon/icon.types'; + +export { LayoutStableText } from './layout-stable-text/layout-stable-text'; +export type { LayoutStableTextProps } from './layout-stable-text/layout-stable-text.types'; diff --git a/packages/agentic/components/src/primitives/layout-stable-text/CONTRACT.md b/packages/agentic/components/src/primitives/layout-stable-text/CONTRACT.md new file mode 100644 index 00000000000..9ce02af76d8 --- /dev/null +++ b/packages/agentic/components/src/primitives/layout-stable-text/CONTRACT.md @@ -0,0 +1,10 @@ +# LayoutStableText contract + +`LayoutStableText` prevents label changes from shifting surrounding layout. + +- `reserve` is required, hidden from accessibility, and retains its text + metrics in layout. +- `visible` is required and overlays the reserved text. +- Consumer text styles are preserved before the primitive's structural styles. +- The root remains inaccessible so the visible text supplies semantics. +- Native root and test props not owned by the primitive are forwarded.