diff --git a/.changeset/fix-avatar-activity-ring-crash.md b/.changeset/fix-avatar-activity-ring-crash.md new file mode 100644 index 0000000000..dda431d510 --- /dev/null +++ b/.changeset/fix-avatar-activity-ring-crash.md @@ -0,0 +1,5 @@ +--- +"@fluentui-react-native/components": patch +--- + +Prevent the Avatar activity ring from crashing React Native Windows Fabric by replacing native outlines with a persistently mounted decorative border. Preserve its token-derived gap, stroke, layout, and accessibility, and cover visibility changes across all avatar sizes. diff --git a/.changeset/fix-native-component-alignment.md b/.changeset/fix-native-component-alignment.md new file mode 100644 index 0000000000..b9cfad8987 --- /dev/null +++ b/.changeset/fix-native-component-alignment.md @@ -0,0 +1,5 @@ +--- +"@fluentui-react-native/components": patch +--- + +Center intrinsic font icons, Avatar initials, and layout-stable labels inside their View frames, and move single-line Input spacing outside the native editor. Correct the compound text-row baseline examples and add alignment regression coverage and authoring guidance. diff --git a/.changeset/macos-loading-motion.md b/.changeset/macos-loading-motion.md new file mode 100644 index 0000000000..3e8121a17b --- /dev/null +++ b/.changeset/macos-loading-motion.md @@ -0,0 +1,6 @@ +--- +'@fluentui-react-native/framework-base': patch +'@fluentui-react-native/components': patch +--- + +Restore loading animations on macOS Fabric by selecting the supported JavaScript animation driver. Keep Skeleton silhouettes visible without motion and calculate Spinner's quarter-circle dash lengths from native geometry. Add native motion and macOS text-alignment regression coverage. diff --git a/.changeset/skeleton-diagonal-gradient.md b/.changeset/skeleton-diagonal-gradient.md new file mode 100644 index 0000000000..1f14e650f2 --- /dev/null +++ b/.changeset/skeleton-diagonal-gradient.md @@ -0,0 +1,5 @@ +--- +'@fluentui-react-native/components': patch +--- + +Replace Skeleton's solid highlight strip with a soft, angled SVG linear-gradient sweep based on the repository's Shimmer pattern. Preserve shared timing, reduced motion, rounded clipping, and visible static placeholders, and mirror travel for RTL. diff --git a/.changeset/storybook-canvas-secondary-fill.md b/.changeset/storybook-canvas-secondary-fill.md new file mode 100644 index 0000000000..918ecc2647 --- /dev/null +++ b/.changeset/storybook-canvas-secondary-fill.md @@ -0,0 +1,5 @@ +--- +'@fluentui-react-native/storybook-desktop-runtime': patch +--- + +Align Storybook's actual canvas background with its preview surface so Secondary Button fills remain visible in Default Flex, light, and dark themes. Preserve explicit story background overrides and native-color fallbacks. diff --git a/.github/skills/agentic-component-authoring/references/styles-and-tokens.md b/.github/skills/agentic-component-authoring/references/styles-and-tokens.md index 26c3882f6e..f2334c1ce5 100644 --- a/.github/skills/agentic-component-authoring/references/styles-and-tokens.md +++ b/.github/skills/agentic-component-authoring/references/styles-and-tokens.md @@ -134,17 +134,67 @@ Native rings default to Windows/macOS; Win32 uses the root-modality-aware custom path requires RNW 0.81.35 or newer. Do not introduce `outline*` focus styles, local modality trackers, or functional border changes to implement a focus ring. +For non-focus indicators such as Avatar's activity ring, reuse `FocusVisual` as a persistent decorative border rather +than native outline properties. This preserves stable border lifetime on renderers affected by late border creation. +The component owns the indicator's meaning, tokens, and visibility, independently of the focus policy. For an outward +ring with a gap, each absolute edge is `-(gap + strokeWidth)` so the border preserves the requested gap without changing +layout. Mount it with its border configured even when hidden; do not switch border width between zero and a nonzero value. + +## Native motion + +Use `useSharedAnimatedLoop` for phase-locked loading indicators. It treats `useNativeDriver` as a request and selects +the JavaScript driver on macOS Fabric, where native-driven transforms currently remain static. Other renderers keep +their requested driver. These continuous loops must use `isInteraction: false` so the JavaScript path does not starve +interaction-deferred work. Preserve reduced-motion gating independently of driver selection. + +Verify animation with changing pixels on the actual native element, not merely an `Animated.loop().start()` assertion. +Keep a visible static silhouette when motion is disabled, and do not rely on web-only SVG `pathLength` normalization; +derive native dash lengths from the actual circumference. + +## Native text and vertical alignment + +Treat the component frame, the native text line box, and the glyph's visible ink as different measurements. `alignItems` +and `justifyContent` align Yoga boxes; they do not move a glyph inside a stretched Text or editor. A font's `fontSize` +is not its measured ascent plus descent. Platform font fallback can make the difference particularly visible on Win32. + +- Use `Icon` for font glyphs. Its sized View centers an intrinsic, non-shrinking Text child. With a fully specified frame, + the Text is absolute with no edge offsets so Yoga measures its natural height instead of clamping it to the frame. + Without a complete frame, normal flow retains intrinsic dimensions. Do not put the icon's + `height` or `lineHeight` on that Text, use a negative margin or transform to nudge one glyph, or depend on + `textAlignVertical` as a cross-platform centering mechanism. Icon dimensions own scaling, just as they do for images + and SVGs; dimensioned font icons do not independently follow text scaling. +- Retain typography line-height tokens for ordinary labels and paragraphs. Do not globally remove line height to fix + one renderer, and do not replace a typography line height with a control's height or the font's em size. +- Centered initials follow the same intrinsic-metrics rule. Avatar owns all typography and uses native Text to avoid + inheriting the themed Text component's body line height. It preserves token font sizes, native text scaling, and + explicit caller line metrics, and uses an absolute child without edge offsets to avoid clamping in the smallest + padded frame. Do not clip initials to an em square or assume `undefined` can clear merged defaults. +- For single-line `TextInput`, reset internal padding and let the native editor measure its font. Put spacing outside + the editor with Yoga margins or a containing View; keep the control's token-derived minimum height separate. + Input demonstrates this without adding another wrapper or changing the caller's final style precedence. + Preserve the pointer target when moving spacing: Input's inaccessible, non-focusable contents row forwards activation + to the editor through its shared focus target, whose ref is composed by the slot render path. Test disabled and + non-focusable behavior and caller ref forwarding too. +- Distinguish centered graphics from aligned text baselines. `CompoundItemLayout` centers arbitrary regions by default. + A text-only row with different label/shortcut font sizes can use `alignItems: 'baseline'` on the root and inline + content row. All participating regions must supply meaningful text baselines; do not impose that policy on images + or action buttons. +- On Android, disable extra font padding for tightly sized decorative glyphs. That setting is not a desktop fix. +- Exercise small and large sizes, font and image/SVG icons, different text metrics, selection, and constrained wrapping. + Check the running native output, not just flattened styles: a snapshot of the wrong Text height can pass indefinitely. + ## Selected text without layout shift When selected text changes weight: - Render an inaccessible Semibold ghost that reserves width and height. -- Overlay the visible label. +- Overlay a View that vertically centers the visible label at its own measured height. Do not stretch the visible Text + itself with `absoluteFillObject`: smaller text will otherwise sit at the top of the reserved area on native renderers. - Keep the ghost and container state-only. - Apply the same wrapping constraints to both labels. -Button uses theme-independent visibility selectors for the hidden and overlaid styles and token-derived typography for -the actual font metrics. +Use `LayoutStableText`, as Button and Tab do, rather than duplicating the ghost/overlay structure. The overlay must +preserve visible-text accessibility and input handling. Typography remains owned by the consuming component. ## Review checklist @@ -157,3 +207,5 @@ the actual font metrics. - User styles are last. - Slot props share resolved color and size values consistently. - Constrained text can wrap unless truncation is an explicit public choice. +- Font frames and reserved labels center intrinsic text; editor spacing is external; text baselines are an explicit + choice distinct from centering arbitrary slots. diff --git a/.github/skills/agentic-component-authoring/references/tests-and-stories.md b/.github/skills/agentic-component-authoring/references/tests-and-stories.md index 006f26b8c7..93aeb2494b 100644 --- a/.github/skills/agentic-component-authoring/references/tests-and-stories.md +++ b/.github/skills/agentic-component-authoring/references/tests-and-stories.md @@ -50,6 +50,11 @@ contracts. Keep exploratory type probes outside package source or remove them be Primitive runtime tests should cover every renderer or source branch and verify forwarding of size, color, accessibility, and test props. +Native motion stories compare repeated element screenshots and honor the runtime reduced-motion preference. +Use `browser.isElementDisplayed(elementId)` and `browser.takeElementScreenshot(elementId)` for native protocol operations; +WebdriverIO's DOM-oriented `element.isDisplayed()` tries unsupported JavaScript execution. Skip pixel checks explicitly +when the driver does not provide element screenshots. + ## Visual snapshots Keep snapshots focused on resolved output rather than the full renderer tree. Button snapshots map each appearance to: @@ -256,5 +261,14 @@ focus-test result; never bypass ownership or authenticated readiness to proceed. A successful bundle proves story discovery and compilation only. For visual changes, inspect the running target-platform story across hover, pressed, disabled, optional-slot, and constrained-content scenarios. +Alignment regressions must distinguish the outer frame from the inner text. LayoutStableText's Overview includes an +executable desktop check that the smaller visible Text has a smaller measured height than its reserve and that their +vertical centers agree within one layout pixel. Checking only their centers would let the old stretched-Text bug pass. +Its measurement fixture exposes an explicit group around the primitive and an accessible visible Text rather than +querying the primitive's intentionally inaccessible layout wrapper. macOS Fabric does not expose paragraph test IDs to +AX, so that endpoint reads the Text's actual `onLayout` measurements from a named `StoryStatus` View instead. +Pair geometry checks with native screenshots for glyph ink and caret placement; native element bounds alone do not +prove optical alignment. Never record an unavailable native check as passed. + Adding a new story file changes Metro's `require.context` catalog and may require restarting Metro and the native app before the running Storybook index includes it. diff --git a/packages/agentic/components/src/AGENTS.md b/packages/agentic/components/src/AGENTS.md index 14f6f6506a..899182d7ce 100644 --- a/packages/agentic/components/src/AGENTS.md +++ b/packages/agentic/components/src/AGENTS.md @@ -25,6 +25,11 @@ the change crosses component boundaries. ## Optimization principles +- Align native text with View layout, not synthetic text heights or baseline offsets. A font's em size, line box, and + visible glyph are different measurements. Use `Icon` for font icons and `LayoutStableText` for reserved labels; follow + the [native alignment rules](../../../../.github/skills/agentic-component-authoring/references/styles-and-tokens.md#native-text-and-vertical-alignment) + for editors, mixed-size text rows, and regression coverage. + - 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. diff --git a/packages/agentic/components/src/components/AGENTS.md b/packages/agentic/components/src/components/AGENTS.md index 9ab2ccda9d..d2511ab848 100644 --- a/packages/agentic/components/src/components/AGENTS.md +++ b/packages/agentic/components/src/components/AGENTS.md @@ -49,6 +49,8 @@ audit. - Follow the [focus visual policy](#focus-visual-policy): create the optional `FocusRing` in the state hook, apply native settings only to the actual focus target, and style the custom ring in the styling phase. Do not introduce component-specific modality trackers or `outline*` focus styling. +- Keep non-focus indicators such as Avatar's activity ring independent of the focus visual policy. + Reuse `FocusVisual` as a persistent decorative border and toggle opacity rather than native outline properties. - Keep render functions free of hooks, token reads, style creation, and slot mutation. - Export the resolved state type and the state, style-application, and render stages from the package root under component-qualified unstable names so another component can reuse the pipeline. diff --git a/packages/agentic/components/src/components/avatar/SPEC.md b/packages/agentic/components/src/components/avatar/SPEC.md index 8051a191dd..13e56c8dfe 100644 --- a/packages/agentic/components/src/components/avatar/SPEC.md +++ b/packages/agentic/components/src/components/avatar/SPEC.md @@ -19,7 +19,14 @@ Avatar is a non-interactive identity marker. It renders exactly one image, icon, `size` defaults to `40` and accepts `16`, `20`, `24`, `28`, `32`, `40`, `56`, or `120`. `activityRing` defaults to `false`. `root` is required; `image`, `icon`, and `initials` are optional slots. The resolved content mode uses image first, then initials, then icon. Icon mode renders a person icon by default. Initials mode defaults to `AB`, uppercases string or numeric content, trims whitespace, and limits output to one character at size `16` or two characters otherwise. -The root contains only the active content slot. Images are absolute-fill and use cover resizing. Icon and initials slots are centered; all content slots are decorative because the root owns any accessible identity. Supplying more than one content slot is accepted for compatibility, resolves through the documented priority, and issues a development warning. +The root contains a persistent decorative activity-ring visual and only the active content slot. Images are absolute-fill and use cover resizing. Icon and initials slots are centered; all content slots are decorative because the root owns any accessible identity. Supplying more than one content slot is accepted for compatibility, resolves through the documented priority, and issues a development warning. + +The circular View centers the initials' intrinsic native line box. Avatar owns +all initials typography and uses native Text without an inherited body line +height or an em-sized line-height clamp, which can clip or displace glyphs. +Font-size tokens, native text scaling, and explicit caller line metrics are preserved. +The initials are an absolute child without edge offsets, so the smallest +avatar's padded frame cannot clamp that intrinsic line box during measurement. The resolved state retains size, activity-ring value, content mode, theme state, and user root style. User style is applied last. Avatar has no interaction-state ownership. @@ -32,12 +39,23 @@ The resolved state retains size, activity-ring value, content mode, theme state, - **AVT-005:** Add no interaction or focus behavior of its own and delegate those semantics to a containing control, while forwarding the broad root `ViewProps` surface. +- **AVT-006:** Keep the activity ring mounted with its border configured from the + first render, hide it through opacity when inactive, and avoid native outline + properties. Its token-derived gap and stroke extend outside the avatar without + changing layout, hit testing, or accessibility. ## Platform behavior An Avatar with `accessibilityLabel` is accessible with React Native image role; callers can also explicitly control `accessible`. Without an informative label, the default root is hidden from accessibility descendants. Its image, icon, and initials children are always hidden so identity is not announced twice. -Windows exposes an informative root as a UI Automation image; macOS exposes it as an AX image. Avatar adds no tab stop or `FocusVisual`, although a caller can opt the forwarded root into focus with `focusable`. The active ring uses React Native root outline properties and does not introduce a separate rendered child or change the requested width and height. +Windows exposes an informative root as a UI Automation image; macOS exposes it as an AX image. Avatar adds no tab stop or focus feedback, although a caller can opt the forwarded root into focus with `focusable`. + +The activity ring reuses `FocusVisual` as a persistent decorative border, not as +a focus indicator. Its absolute edges are inset by the negative sum of the +activity-ring gap and stroke width. The same border remains configured when +inactive; only opacity changes. This avoids the native outline path that can +fail-fast in React Native Windows 0.81 Fabric on a background-filled avatar, +including when the initial render already requests an active ring. ## Divergences from Flex @@ -48,10 +66,11 @@ Windows exposes an informative root as a UI Automation image; macOS exposes it a ## Conformance -| Requirement | Evidence | -| ----------- | ---------------------------------------------------------------------------- | -| AVT-001 | `avatar.types.ts`, `useAvatar.ts`, `avatar.test.tsx`, `avatar.types.test.ts` | -| AVT-002 | `useAvatarStyles.ts`, `renderAvatar.tsx`, `avatar.test.tsx` | -| AVT-003 | `avatar.styles.ts`, `useAvatarStyles.ts`, `avatar.test.tsx` | -| AVT-004 | `useAvatar.ts`, `useAvatarStyles.ts`, `avatar.test.tsx` | -| AVT-005 | `avatar.types.ts`, `useAvatar.ts`, `avatar.stories.tsx` | +| Requirement | Evidence | +| ----------- | ----------------------------------------------------------------------------------------------------- | +| AVT-001 | `avatar.types.ts`, `useAvatar.ts`, `avatar.test.tsx`, `avatar.types.test.ts` | +| AVT-002 | `useAvatarStyles.ts`, `renderAvatar.tsx`, `avatar.test.tsx` | +| AVT-003 | `avatar.styles.ts`, `useAvatarStyles.ts`, `avatar.test.tsx` | +| AVT-004 | `useAvatar.ts`, `useAvatarStyles.ts`, `avatar.test.tsx` | +| AVT-005 | `avatar.types.ts`, `useAvatar.ts`, `avatar.stories.tsx` | +| AVT-006 | `avatar.styles.ts`, `useAvatarStyles.ts`, `renderAvatar.tsx`, `avatar.test.tsx`, `avatar.stories.tsx` | diff --git a/packages/agentic/components/src/components/avatar/avatar.stories.tsx b/packages/agentic/components/src/components/avatar/avatar.stories.tsx index 8c7849b127..3520585a75 100644 --- a/packages/agentic/components/src/components/avatar/avatar.stories.tsx +++ b/packages/agentic/components/src/components/avatar/avatar.stories.tsx @@ -3,9 +3,11 @@ import type { ReactNode } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import type { Meta, StoryObj } from '@storybook/react-native'; +import type { WdioStory } from '@fluentui-react-native/storybook-desktop/testing'; import { Avatar } from './avatar'; -import type { AvatarSize } from './avatar.types'; +import { StoryStatus } from '../../common/StoryStatus.story-helpers'; +import type { AvatarProps, AvatarSize } from './avatar.types'; type StoryGroupProps = { children: ReactNode; @@ -44,7 +46,7 @@ const meta: Meta = { export default meta; -type Story = StoryObj; +type Story = WdioStory>; export const Default: Story = {}; @@ -93,13 +95,39 @@ export const ActivityRing: Story = { activityRing: true, initials: 'LM', }, + render: (args: AvatarProps) => ( + + + {`Activity ring ${args.activityRing ? 'on' : 'off'}, size ${args.size}`} + + ), parameters: { docs: { description: { - story: 'The optional activity ring renders as an outline offset and width that scale with the avatar size.', + story: 'The activity ring uses a persistent decorative border with a gap and stroke that scale with the avatar size.', }, }, }, + wdio: { + 'toggles the activity ring at every size without crashing or changing avatar bounds': async ({ browser, desktop, expect }) => { + const assert: typeof import('node:assert') = (await import('node:assert')).default; + for (const size of [16, 20, 24, 28, 32, 40, 56, 120]) { + for (const activityRing of [false, true]) { + await desktop.session.updateStoryArgs('components-avatar--activity-ring', { activityRing, size }); + await expect(await browser.$('~agentic-storybook-avatar-ring-state')).toHaveText( + `Activity ring ${activityRing ? 'on' : 'off'}, size ${size}`, + ); + const avatar = await browser.$('~agentic-storybook-avatar-activity-ring'); + await expect(avatar).toExist(); + const bounds = await browser.getElementRect(await avatar.elementId); + assert( + Math.abs(bounds.width - size) <= 1 && Math.abs(bounds.height - size) <= 1, + 'The activity ring must not resize the avatar.', + ); + } + } + }, + }, }; const styles = StyleSheet.create({ diff --git a/packages/agentic/components/src/components/avatar/avatar.styles.ts b/packages/agentic/components/src/components/avatar/avatar.styles.ts index 856744db07..86881e2e27 100644 --- a/packages/agentic/components/src/components/avatar/avatar.styles.ts +++ b/packages/agentic/components/src/components/avatar/avatar.styles.ts @@ -5,7 +5,7 @@ import type { FlexTokens } from '@fluentui-react-native/design'; import { getThemedStateStyleFactory } from '@fluentui-react-native/design/styling'; import type { StateNames, StyleDefinition } from '@fluentui-react-native/design/styling'; -import type { AvatarContentMode, AvatarSize, AvatarState } from './avatar.types'; +import type { AvatarSize, AvatarState } from './avatar.types'; export const avatarStyles = StyleSheet.create({ root: { @@ -19,73 +19,48 @@ export const avatarStyles = StyleSheet.create({ }, initials: { flexShrink: 1, + includeFontPadding: false, padding: 0, + position: 'absolute', textAlign: 'center', - textAlignVertical: 'center', }, }); const avatarSizeStates = ['16', '20', '24', '28', '32', '40', '56', '120'] as const; const avatarModeStates = ['image', 'icon', 'initials'] as const; -const avatarRingStates = ['activityRing'] as const; -const avatarRingStateLevels = [avatarRingStates] as const; - -const avatarRootStateLevels = [avatarSizeStates, avatarModeStates, avatarRingStates] as const; +const avatarRootStateLevels = [avatarSizeStates, avatarModeStates] as const; type AvatarRootStateLevels = typeof avatarRootStateLevels; type AvatarRootState = StateNames; -const avatarInitialsStateLevels = [avatarSizeStates] as const; -type AvatarInitialsStateLevels = typeof avatarInitialsStateLevels; -type AvatarInitialsState = StateNames; +const avatarSizeStateLevels = [avatarSizeStates] as const; +type AvatarSizeStateLevels = typeof avatarSizeStateLevels; +type AvatarSizeState = StateNames; function createRingStyle({ color, strokeWidth }: FlexTokens, offset: 'thin' | 'thick', width: 'thin' | 'thick' | 'thicker'): ViewStyle { + const inset = -(strokeWidth[offset] + strokeWidth[width]); return { - outlineColor: color.strokeBrandLoud, - outlineOffset: strokeWidth[offset], - outlineStyle: 'solid', - outlineWidth: strokeWidth[width], - }; -} - -function createModeStyles( - ringStyle: ViewStyle, - tokens: FlexTokens, -): Record> { - return { - image: { - backgroundColor: tokens.color.backgroundNeutralTransparent, - padding: 0, - activityRing: ringStyle, - }, - icon: { - activityRing: ringStyle, - }, - initials: { - activityRing: ringStyle, - }, + borderColor: color.strokeBrandLoud, + borderStyle: 'solid', + borderWidth: strokeWidth[width], + bottom: inset, + left: inset, + right: inset, + top: inset, }; } function createSizeStyleDefinition(tokens: FlexTokens, size: AvatarSize): StyleDefinition { const { borderRadius, color, spacing } = tokens; - const ringStyle = - size === 120 - ? createRingStyle(tokens, 'thick', 'thicker') - : size === 56 - ? createRingStyle(tokens, 'thin', 'thicker') - : size === 16 - ? createRingStyle(tokens, 'thin', 'thin') - : createRingStyle(tokens, 'thin', 'thick'); - const modeStyles = createModeStyles(ringStyle, tokens); return { alignItems: 'center', backgroundColor: color.backgroundNeutralSoft, borderRadius: borderRadius.circular, height: size, - image: modeStyles.image, - icon: modeStyles.icon, - initials: modeStyles.initials, + image: { + backgroundColor: color.backgroundNeutralTransparent, + padding: 0, + }, minHeight: size, minWidth: size, padding: @@ -106,7 +81,6 @@ function createSizeStyleDefinition(tokens: FlexTokens, size: AvatarSize): StyleD : spacing.layoutBase400, justifyContent: 'center', width: size, - // retain the ring style at every size, while letting the content mode supply the final branch. }; } @@ -132,33 +106,43 @@ const getThemedAvatarRootStyle = getThemedStateStyleFactory( ); function getAvatarRootStateSource(state: AvatarState): AvatarRootState[] { - const source: AvatarRootState[] = [String(state.size) as AvatarRootState, state.contentMode]; - if (state.activityRing) { - source.push('activityRing'); - } - return source; + return [String(state.size) as AvatarRootState, state.contentMode]; } export function getAvatarRootStyle(state: AvatarState): ViewStyle { return getThemedAvatarRootStyle(state, getAvatarRootStateSource(state)); } +const getThemedAvatarActivityRingStyle = getThemedStateStyleFactory( + 'Avatar.activityRing', + (tokens: FlexTokens): StyleDefinition => ({ + ...createRingStyle(tokens, 'thin', 'thick'), + borderRadius: tokens.borderRadius.circular, + '16': createRingStyle(tokens, 'thin', 'thin'), + '56': createRingStyle(tokens, 'thin', 'thicker'), + '120': createRingStyle(tokens, 'thick', 'thicker'), + }), + avatarSizeStateLevels, +); + +export function getAvatarActivityRingStyle(state: AvatarState): ViewStyle { + return getThemedAvatarActivityRingStyle(state, [String(state.size) as AvatarSizeState]); +} + function createInitialsTextStyle(size: number): TextStyle { return { fontSize: size, - lineHeight: size, }; } const getThemedAvatarInitialsStyle = getThemedStateStyleFactory( 'Avatar.initials', - ({ fontFamily, fontSize, fontWeight }: FlexTokens): StyleDefinition => { + ({ fontFamily, fontSize, fontWeight }: FlexTokens): StyleDefinition => { return { fontFamily: fontFamily.functional, fontWeight: fontWeight.functionalRegular, padding: 0, textAlign: 'center', - textAlignVertical: 'center', textTransform: 'uppercase', '16': createInitialsTextStyle(fontSize.functionalCaption), '20': createInitialsTextStyle(fontSize.functionalCaption), @@ -170,11 +154,11 @@ const getThemedAvatarInitialsStyle = getThemedStateStyleFactory( '120': createInitialsTextStyle(fontSize.functionalTitleLarge), }; }, - avatarInitialsStateLevels, + avatarSizeStateLevels, ); -function getAvatarInitialsStateSource(state: AvatarState): AvatarInitialsState[] { - return [String(state.size) as AvatarInitialsState]; +function getAvatarInitialsStateSource(state: AvatarState): AvatarSizeState[] { + return [String(state.size) as AvatarSizeState]; } export function getAvatarInitialsStyle(state: AvatarState): TextStyle { diff --git a/packages/agentic/components/src/components/avatar/avatar.test.tsx b/packages/agentic/components/src/components/avatar/avatar.test.tsx index 1159b6f7dd..b188b2a83b 100644 --- a/packages/agentic/components/src/components/avatar/avatar.test.tsx +++ b/packages/agentic/components/src/components/avatar/avatar.test.tsx @@ -92,15 +92,74 @@ describe('Avatar', () => { const tokens = defaultFlexTokens; expect(getRootStyle(component)).toMatchObject({ - outlineColor: tokens.color.strokeBrandLoud, - outlineOffset: tokens.strokeWidth.thick, - outlineStyle: 'solid', - outlineWidth: tokens.strokeWidth.thicker, width: 120, height: 120, }); + expect(StyleSheet.flatten(component.getByTestId('avatar-activity-ring', { includeHiddenElements: true }).props.style)).toMatchObject({ + borderColor: tokens.color.strokeBrandLoud, + borderRadius: tokens.borderRadius.circular, + borderStyle: 'solid', + borderWidth: tokens.strokeWidth.thicker, + bottom: -(tokens.strokeWidth.thick + tokens.strokeWidth.thicker), + left: -(tokens.strokeWidth.thick + tokens.strokeWidth.thicker), + right: -(tokens.strokeWidth.thick + tokens.strokeWidth.thicker), + top: -(tokens.strokeWidth.thick + tokens.strokeWidth.thicker), + position: 'absolute', + }); }); + it.each([16, 20, 24, 28, 32, 40, 56, 120] as const)( + 'keeps the same decorative ring mounted in every content mode at size %s', + async (size) => { + const tokens = defaultFlexTokens; + const width = size === 16 ? tokens.strokeWidth.thin : size >= 56 ? tokens.strokeWidth.thicker : tokens.strokeWidth.thick; + const offset = size === 120 ? tokens.strokeWidth.thick : tokens.strokeWidth.thin; + const modes: React.ComponentProps[] = [ + { initials: 'AB' }, + { icon: { fontSource: { codepoint: 0x2605, fontFamily: 'Arial' } } }, + { image: { source: { uri: 'avatar.png' } } }, + ]; + + for (const content of modes) { + const props = { ...content, accessibilityLabel: `Avatar ${size}`, size }; + const component = await renderAvatar(props); + const ring = component.getByTestId('avatar-activity-ring', { includeHiddenElements: true }); + const rootStyle = getRootStyle(component); + expect(ring.props).toMatchObject({ + accessibilityElementsHidden: true, + accessible: false, + collapsable: false, + focusable: false, + importantForAccessibility: 'no-hide-descendants', + pointerEvents: 'none', + }); + expect(StyleSheet.flatten(ring.props.style)).toMatchObject({ + borderColor: tokens.color.strokeBrandLoud, + borderWidth: width, + bottom: -(offset + width), + left: -(offset + width), + opacity: 0, + right: -(offset + width), + top: -(offset + width), + }); + for (const key of ['outlineColor', 'outlineOffset', 'outlineStyle', 'outlineWidth']) { + expect(rootStyle).not.toHaveProperty(key); + } + + await component.rerender(); + expect(component.getByTestId('avatar-activity-ring', { includeHiddenElements: true })).toBe(ring); + expect(StyleSheet.flatten(ring.props.style).opacity).toBeUndefined(); + expect(getRootStyle(component)).toEqual(rootStyle); + expect(component.getAllByRole('image')).toHaveLength(1); + + await component.rerender(); + expect(component.getByTestId('avatar-activity-ring', { includeHiddenElements: true })).toBe(ring); + expect(StyleSheet.flatten(ring.props.style).opacity).toBe(0); + await component.unmount(); + } + }, + ); + it('applies the correct size and initials scale across the supported sizes', async () => { const tokens = defaultFlexTokens; const sizes = [ @@ -118,10 +177,15 @@ describe('Avatar', () => { const component = await renderAvatar({ accessibilityLabel: `Avatar ${size}`, initials: 'LM', size }); const expectedInitials = size === 16 ? 'L' : 'LM'; expect(getRootStyle(component)).toMatchObject({ height: size, minHeight: size, minWidth: size, padding, width: size }); - expect(StyleSheet.flatten(component.getByText(expectedInitials, { includeHiddenElements: true }).props.style)).toMatchObject({ + const initialsStyle = StyleSheet.flatten(component.getByText(expectedInitials, { includeHiddenElements: true }).props.style); + expect(initialsStyle).toMatchObject({ fontSize, - lineHeight: fontSize, + includeFontPadding: false, + position: 'absolute', }); + expect(initialsStyle.lineHeight).toBeUndefined(); + expect(initialsStyle.height).toBeUndefined(); + expect(initialsStyle.textAlignVertical).toBeUndefined(); } }); @@ -149,4 +213,13 @@ describe('Avatar', () => { expect(getRootStyle(component).backgroundColor).toBe('hotpink'); }); + + it('preserves explicit initials line metrics after intrinsic defaults', async () => { + const component = await renderAvatar({ + accessibilityLabel: 'Initials', + initials: { children: 'AB', style: { lineHeight: 24 } }, + }); + + expect(StyleSheet.flatten(component.getByText('AB', { includeHiddenElements: true }).props.style).lineHeight).toBe(24); + }); }); diff --git a/packages/agentic/components/src/components/avatar/avatar.types.ts b/packages/agentic/components/src/components/avatar/avatar.types.ts index dae8bc011c..b8f3522064 100644 --- a/packages/agentic/components/src/components/avatar/avatar.types.ts +++ b/packages/agentic/components/src/components/avatar/avatar.types.ts @@ -1,4 +1,4 @@ -import type { Image, StyleProp, View, ViewStyle } from 'react-native'; +import type { Image, StyleProp, Text, View, ViewStyle } from 'react-native'; import type { ComponentProps, ComponentState, @@ -9,8 +9,8 @@ import type { } from '@fluentui-react-native/framework-base'; import type { ThemeState } from '@fluentui-react-native/design'; +import type { FocusVisualProps } from '../../primitives/focus-visual/focus-visual.types'; import type { Icon } from '../../primitives/icon/icon'; -import type { Text } from '../text/text'; export type AvatarSize = 16 | 20 | 24 | 28 | 32 | 40 | 56 | 120; export type AvatarContentMode = 'image' | 'icon' | 'initials'; @@ -41,6 +41,7 @@ export type AvatarProps = AvatarStateProps & ComponentProps & Required & ThemeState & { + activityRingProps?: FocusVisualProps; contentMode: AvatarContentMode; userStyle?: StyleProp; }; diff --git a/packages/agentic/components/src/components/avatar/renderAvatar.tsx b/packages/agentic/components/src/components/avatar/renderAvatar.tsx index 6d2843cf2f..bb6da96c6c 100644 --- a/packages/agentic/components/src/components/avatar/renderAvatar.tsx +++ b/packages/agentic/components/src/components/avatar/renderAvatar.tsx @@ -1,4 +1,5 @@ /** @jsxImportSource @fluentui-react-native/framework-base */ +import { FocusVisual } from '../../primitives/focus-visual/focus-visual'; import type { AvatarState } from './avatar.types'; export function renderAvatar_unstable(state: AvatarState) { @@ -6,6 +7,7 @@ export function renderAvatar_unstable(state: AvatarState) { return ( + {contentMode === 'image' && Image && } {contentMode === 'icon' && Icon && } {contentMode === 'initials' && Initials && } diff --git a/packages/agentic/components/src/components/avatar/spec/accessibility.md b/packages/agentic/components/src/components/avatar/spec/accessibility.md index 09a688c015..39b61135fe 100644 --- a/packages/agentic/components/src/components/avatar/spec/accessibility.md +++ b/packages/agentic/components/src/components/avatar/spec/accessibility.md @@ -4,4 +4,8 @@ Provide `accessibilityLabel` when the avatar is the identity exposed to assistiv The root honors an explicit `accessible` value, while decorative image, icon, and initials slots remain inaccessible in every content mode. `activityRing` has no exposed state or label; provide nearby text when active or collaboration status must be communicated. +The persistent activity-ring View is hidden from accessibility, non-focusable, +and excluded from hit testing in both visible and hidden states. It does not +introduce another announced image or imply keyboard focus. + On Windows, an informative avatar maps to a UI Automation image. On macOS, it maps to an AX image. Avatar is not a control and should not be given a focusable role. diff --git a/packages/agentic/components/src/components/avatar/spec/interaction.md b/packages/agentic/components/src/components/avatar/spec/interaction.md index d83259bc5e..e8b2bcf002 100644 --- a/packages/agentic/components/src/components/avatar/spec/interaction.md +++ b/packages/agentic/components/src/components/avatar/spec/interaction.md @@ -1,5 +1,10 @@ # Avatar interaction -Avatar has no press, hover, disabled, selected, or focus state. It does not receive keyboard focus and renders no `FocusVisual`. Pointer and keyboard behavior belong to a wrapper when an avatar is part of a person picker, list item, or profile action. +Avatar has no press, hover, disabled, selected, or focus state. It adds no keyboard focus or focus feedback. Pointer and keyboard behavior belong to a wrapper when an avatar is part of a person picker, list item, or profile action. -Changing the content slot or `activityRing` changes the rendered presentation without a component-owned animation. The ring is root styling, so enabling it does not add a hit target or alter the root's requested dimensions. No reduced-motion behavior is needed because Avatar does not animate. +Changing the content slot or `activityRing` changes the rendered presentation without a component-owned animation. +The activity ring reuses the persistent border structure of `FocusVisual`, but +its visibility is controlled solely by `activityRing`, never focus. Toggling the +prop changes opacity without mounting another native border. The ring does not +add a hit target, change tab order, or alter the root's requested dimensions. +No reduced-motion behavior is needed because Avatar does not animate. diff --git a/packages/agentic/components/src/components/avatar/spec/source.json b/packages/agentic/components/src/components/avatar/spec/source.json index fe16713c01..16453cf806 100644 --- a/packages/agentic/components/src/components/avatar/spec/source.json +++ b/packages/agentic/components/src/components/avatar/spec/source.json @@ -3,7 +3,7 @@ "component": "avatar", "lifecycle": "implemented", "conformance": "reviewed", - "reviewedAt": "2026-08-28", + "reviewedAt": "2026-09-21", "sources": [ { "id": "flex-component", @@ -99,6 +99,10 @@ { "id": "AVT-005", "evidence": ["avatar.types.ts", "useAvatar.ts", "avatar.stories.tsx"] + }, + { + "id": "AVT-006", + "evidence": ["avatar.styles.ts", "useAvatarStyles.ts", "renderAvatar.tsx", "avatar.test.tsx", "avatar.stories.tsx"] } ] } diff --git a/packages/agentic/components/src/components/avatar/spec/tokens.yaml b/packages/agentic/components/src/components/avatar/spec/tokens.yaml index 42d3ef4ebc..09f91a04f7 100644 --- a/packages/agentic/components/src/components/avatar/spec/tokens.yaml +++ b/packages/agentic/components/src/components/avatar/spec/tokens.yaml @@ -28,20 +28,32 @@ bindings: color: color.foregroundNeutralPrimary fontFamily: fontFamily.functional fontWeight: fontWeight.functionalRegular + layout: + position: absolute + # Native Text supplies intrinsic line metrics without an inherited body lineHeight. size: - '16': { fontSize: fontSize.functionalCaption, lineHeight: fontSize.functionalCaption } - '20': { fontSize: fontSize.functionalCaption, lineHeight: fontSize.functionalCaption } - '24': { fontSize: fontSize.functionalCaption, lineHeight: fontSize.functionalCaption } - '28': { fontSize: fontSize.functionalBodySmall, lineHeight: fontSize.functionalBodySmall } - '32': { fontSize: fontSize.functionalBodyMedium, lineHeight: fontSize.functionalBodyMedium } - '40': { fontSize: fontSize.functionalBodyLarge, lineHeight: fontSize.functionalBodyLarge } - '56': { fontSize: fontSize.functionalTitleSmall, lineHeight: fontSize.functionalTitleSmall } - '120': { fontSize: fontSize.functionalTitleLarge, lineHeight: fontSize.functionalTitleLarge } + '16': { fontSize: fontSize.functionalCaption } + '20': { fontSize: fontSize.functionalCaption } + '24': { fontSize: fontSize.functionalCaption } + '28': { fontSize: fontSize.functionalBodySmall } + '32': { fontSize: fontSize.functionalBodyMedium } + '40': { fontSize: fontSize.functionalBodyLarge } + '56': { fontSize: fontSize.functionalTitleSmall } + '120': { fontSize: fontSize.functionalTitleLarge } icon: color: color.foregroundNeutralPrimary size: { '16': 12, '20': 16, '24': 16, '28': 16, '32': 20, '40': 24, '56': 32, '120': 48 } activityRing: + structure: persistent FocusVisual border color: color.strokeBrandLoud + borderRadius: borderRadius.circular + borderStyle: solid + layout: + position: absolute + inset: -(offset + width) + visibility: + active: opaque + inactive: opacity 0 '16': { offset: strokeWidth.thin, width: strokeWidth.thin } '20': { offset: strokeWidth.thin, width: strokeWidth.thick } '24': { offset: strokeWidth.thin, width: strokeWidth.thick } diff --git a/packages/agentic/components/src/components/avatar/useAvatar.ts b/packages/agentic/components/src/components/avatar/useAvatar.ts index f9dbcbb0db..6525f3d389 100644 --- a/packages/agentic/components/src/components/avatar/useAvatar.ts +++ b/packages/agentic/components/src/components/avatar/useAvatar.ts @@ -1,12 +1,11 @@ import * as React from 'react'; -import { Image, View } from 'react-native'; +import { Image, Text, View } from 'react-native'; import { useThemeState } from '@fluentui-react-native/design'; import { useOptionalSlot, useSlot } from '@fluentui-react-native/framework-base'; import { semanticIconSources } from '../../common/iconSources'; import { Icon } from '../../primitives/icon/icon'; -import { Text } from '../text/text'; import type { AvatarProps, AvatarState } from './avatar.types'; const defaultAvatarIcon = { diff --git a/packages/agentic/components/src/components/avatar/useAvatarStyles.ts b/packages/agentic/components/src/components/avatar/useAvatarStyles.ts index 0fe07ad177..24c91a3d44 100644 --- a/packages/agentic/components/src/components/avatar/useAvatarStyles.ts +++ b/packages/agentic/components/src/components/avatar/useAvatarStyles.ts @@ -3,7 +3,7 @@ import type { ImageStyle, StyleProp, TextStyle, ViewStyle } from 'react-native'; import { attachSlotProps } from '@fluentui-react-native/framework-base'; import { hiddenFromAccessibilityProps } from '../../common/accessibility'; -import { avatarStyles, getAvatarIconSize, getAvatarInitialsStyle, getAvatarRootStyle } from './avatar.styles'; +import { avatarStyles, getAvatarActivityRingStyle, getAvatarIconSize, getAvatarInitialsStyle, getAvatarRootStyle } from './avatar.styles'; import type { AvatarState } from './avatar.types'; export function useAvatarStyles_unstable(state: AvatarState) { @@ -18,6 +18,11 @@ export function useAvatarStyles_unstable(state: AvatarState) { const initialsStyle: StyleProp = [avatarStyles.initials, getAvatarInitialsStyle(state), { color: foregroundColor }]; const iconSize = getAvatarIconSize(state.size); + state.activityRingProps = { + style: getAvatarActivityRingStyle(state), + testID: 'avatar-activity-ring', + visible: state.activityRing, + }; attachSlotProps(state.root, { style: rootStyle }); if (state.image) { attachSlotProps(state.image, { diff --git a/packages/agentic/components/src/components/badge/badge.test.tsx b/packages/agentic/components/src/components/badge/badge.test.tsx index f59ce6d0d6..5a3999b1b6 100644 --- a/packages/agentic/components/src/components/badge/badge.test.tsx +++ b/packages/agentic/components/src/components/badge/badge.test.tsx @@ -46,10 +46,12 @@ describe('Badge', () => { fontSize: tokens.fontSize.functionalBodySmall, }); expect(StyleSheet.flatten(component.getByTestId('leading-icon').props.style)).toMatchObject({ - color: tokens.color.foregroundBrandPrimary, height: 16, width: 16, }); + expect(StyleSheet.flatten(component.getByText(String.fromCodePoint(badgeIcon.fontSource.codepoint)).props.style)).toMatchObject({ + color: tokens.color.foregroundBrandPrimary, + }); }); it('renders icon-only badges with an accessible image role and ignores the label slot', async () => { diff --git a/packages/agentic/components/src/components/button/__snapshots__/button.test.tsx.snap b/packages/agentic/components/src/components/button/__snapshots__/button.test.tsx.snap index ef7c16b7d6..99fa028ba6 100644 --- a/packages/agentic/components/src/components/button/__snapshots__/button.test.tsx.snap +++ b/packages/agentic/components/src/components/button/__snapshots__/button.test.tsx.snap @@ -641,18 +641,13 @@ exports[`Button matches the selected visual state snapshot across appearances 1` "textAlign": "center", }, { - "bottom": 0, "color": "#ffffff", "flexShrink": 1, "fontFamily": "Helvetica Neue", "fontSize": 14, "fontWeight": "600", - "left": 0, "lineHeight": 20, - "position": "absolute", - "right": 0, "textAlign": "center", - "top": 0, }, ], "rootStyle": { @@ -686,18 +681,13 @@ exports[`Button matches the selected visual state snapshot across appearances 1` "textAlign": "center", }, { - "bottom": 0, "color": "#ffffff", "flexShrink": 1, "fontFamily": "Helvetica Neue", "fontSize": 14, "fontWeight": "600", - "left": 0, "lineHeight": 20, - "position": "absolute", - "right": 0, "textAlign": "center", - "top": 0, }, ], "rootStyle": { @@ -731,18 +721,13 @@ exports[`Button matches the selected visual state snapshot across appearances 1` "textAlign": "center", }, { - "bottom": 0, "color": "#ffffff", "flexShrink": 1, "fontFamily": "Helvetica Neue", "fontSize": 14, "fontWeight": "600", - "left": 0, "lineHeight": 20, - "position": "absolute", - "right": 0, "textAlign": "center", - "top": 0, }, ], "rootStyle": { @@ -776,18 +761,13 @@ exports[`Button matches the selected visual state snapshot across appearances 1` "textAlign": "center", }, { - "bottom": 0, "color": "#242424", "flexShrink": 1, "fontFamily": "Helvetica Neue", "fontSize": 14, "fontWeight": "600", - "left": 0, "lineHeight": 20, - "position": "absolute", - "right": 0, "textAlign": "center", - "top": 0, }, ], "rootStyle": { diff --git a/packages/agentic/components/src/components/button/button.test.tsx b/packages/agentic/components/src/components/button/button.test.tsx index 936d73ce50..ba5c564178 100644 --- a/packages/agentic/components/src/components/button/button.test.tsx +++ b/packages/agentic/components/src/components/button/button.test.tsx @@ -146,7 +146,8 @@ describe('Button', () => { expect(component.queryByTestId('regular-icon')).toBeNull(); expect(labels).toHaveLength(2); expect(StyleSheet.flatten(labels[0].props.style)).toMatchObject({ fontWeight: '600', opacity: 0 }); - expect(StyleSheet.flatten(labels[1].props.style)).toMatchObject({ fontWeight: '600', position: 'absolute' }); + expect(StyleSheet.flatten(labels[1].props.style)).toMatchObject({ fontWeight: '600' }); + expect(StyleSheet.flatten(labels[1].parent?.props.style)).toMatchObject({ position: 'absolute', justifyContent: 'center' }); }); it('renders selection without changing it on press', async () => { @@ -174,7 +175,7 @@ describe('Button', () => { fontWeight: tokens.fontWeight.functionalSemibold, opacity: 0, }); - expect(StyleSheet.flatten(labels[1].props.style)).toMatchObject({ position: 'absolute' }); + expect(StyleSheet.flatten(labels[1].parent?.props.style)).toMatchObject({ position: 'absolute', justifyContent: 'center' }); } }); diff --git a/packages/agentic/components/src/components/checkbox/checkbox.test.tsx b/packages/agentic/components/src/components/checkbox/checkbox.test.tsx index c176b8e62a..d698f95f0d 100644 --- a/packages/agentic/components/src/components/checkbox/checkbox.test.tsx +++ b/packages/agentic/components/src/components/checkbox/checkbox.test.tsx @@ -304,7 +304,7 @@ describe('Checkbox', () => { expect(StyleSheet.flatten(indicator.props.style).backgroundColor).toBe(expectedBackground); expect(StyleSheet.flatten(label.props.style).color).toBe(expectedLabelColor); if (status !== 'unchecked') { - const icon = component.getByTestId(status === 'indeterminate' ? 'checkbox-dash-icon' : 'checkbox-check-icon'); + const icon = component.getByText(status === 'indeterminate' ? '\u2212' : '\u2713'); expect(StyleSheet.flatten(icon.props.style).color).toBe(colors.foregroundBrandOnloud); } }); diff --git a/packages/agentic/components/src/components/divider/divider.stories.tsx b/packages/agentic/components/src/components/divider/divider.stories.tsx index 7230390c17..b651ca7015 100644 --- a/packages/agentic/components/src/components/divider/divider.stories.tsx +++ b/packages/agentic/components/src/components/divider/divider.stories.tsx @@ -63,11 +63,11 @@ export const Overview: Story = { - + Side A - + - + Side B @@ -174,6 +174,7 @@ const styles = StyleSheet.create({ }, verticalCell: { alignItems: 'center', + justifyContent: 'center', width: 72, }, verticalDemo: { @@ -186,5 +187,6 @@ const styles = StyleSheet.create({ alignItems: 'stretch', flexDirection: 'row', gap: 12, + height: 160, }, }); diff --git a/packages/agentic/components/src/components/input/SPEC.md b/packages/agentic/components/src/components/input/SPEC.md index 2e9c8a58f8..efbcee54c0 100644 --- a/packages/agentic/components/src/components/input/SPEC.md +++ b/packages/agentic/components/src/components/input/SPEC.md @@ -92,6 +92,20 @@ text composition, clipboard behavior, and character entry. Input adds no key handling of its own and does not intercept Tab, so the platform tab order and the platform text-editing shortcuts apply unchanged. +The single-line editor uses intrinsic native font metrics and zero internal +padding. Token-derived vertical spacing is an external Yoga margin, not native +text padding. Line-height tokens still determine the control's minimum height; +they do not force a line box on the editor. The surrounding centered rows align +that intrinsic editor with the icon frames. + +The contents row is a non-accessible, non-focusable Pressable that forwards a +press in the external spacing to the editor. This preserves the field's pointer +target without stretching its text. Activation requests pointer focus through +the shared focus target, respecting the resolved `focusable` value. Disabled +fields do not forward activation; read-only fields remain focusable by default. +The internal editor ref is composed with any caller slot ref through the slot +render path. + Hover and press state are tracked from events on the text input rather than the surrounding container, so pointer feedback follows the text area. Disabling the control clears focus, hover, and press so a disabled field never keeps stale diff --git a/packages/agentic/components/src/components/input/input.styles.ts b/packages/agentic/components/src/components/input/input.styles.ts index 87a8787ecb..6cb01be8de 100644 --- a/packages/agentic/components/src/components/input/input.styles.ts +++ b/packages/agentic/components/src/components/input/input.styles.ts @@ -42,7 +42,7 @@ export const inputStyles = StyleSheet.create({ flexGrow: 1, flexShrink: 1, minWidth: 0, - paddingHorizontal: 0, + padding: 0, }, underline: { left: 0, @@ -82,7 +82,6 @@ function getMetrics(tokens: FlexTokens, size: InputSize) { horizontalPadding: toNumber(spacing.componentBase150), iconSize: toNumber(size160), textFontSize: toNumber(fontSize.functionalBodySmall), - textLineHeight: toNumber(lineHeight.functionalBodySmall), verticalPadding: toNumber(spacing.componentBase100), }, medium: { @@ -90,7 +89,6 @@ function getMetrics(tokens: FlexTokens, size: InputSize) { horizontalPadding: toNumber(spacing.componentBase250), iconSize: toNumber(size200), textFontSize: toNumber(fontSize.functionalBodyMedium), - textLineHeight: toNumber(lineHeight.functionalBodyMedium), verticalPadding: toNumber(spacing.componentBase150), }, large: { @@ -98,7 +96,6 @@ function getMetrics(tokens: FlexTokens, size: InputSize) { horizontalPadding: toNumber(spacing.componentBase300), iconSize: toNumber(size240), textFontSize: toNumber(fontSize.functionalBodyLarge), - textLineHeight: toNumber(lineHeight.functionalBodyLarge), verticalPadding: toNumber(spacing.componentBase200), }, } as const; @@ -259,15 +256,13 @@ function createTextInputDefinition(tokens: FlexTokens): StyleDefinition { const request = observed.state?.focusTarget.requestFocus(); expect(request?.status).toBe(expected ? 'requested' : 'not-focusable'); }); + const requestFocus = jest.spyOn(observed.state!.focusTarget, 'requestFocus'); + await fireEvent.press(getContents(component)); + if (props.disabled) { + expect(requestFocus).not.toHaveBeenCalled(); + } else { + expect(requestFocus).toHaveBeenCalledWith('pointer'); + expect(requestFocus.mock.results[0].value.status).toBe(expected ? 'requested' : 'not-focusable'); + } await fireEvent(getTextbox(component), 'focus', {}); expect(observed.state?.focused).toBe(expected); + if (expected) { + expect(observed.state?.focusTarget.getSnapshot().intent).toBe('pointer'); + } }); it('renders a textbox with outline styling by default', async () => { @@ -173,10 +184,12 @@ describe('Input', () => { const colors = defaultFlexTokens.color; expect(flattenStyle(component.getByTestId('icon-start').props.style)).toMatchObject({ - color: colors.foregroundNeutralPrimary, height: 24, width: 24, }); + expect(flattenStyle(component.getByText('\u2605').props.style)).toMatchObject({ + color: colors.foregroundNeutralPrimary, + }); expect(component.getByTestId('icon-end-1').props.style).toMatchObject({ height: 24, width: 24 }); expect(component.getByTestId('icon-end-2').props.style).toMatchObject({ height: 24, width: 24 }); }); @@ -186,30 +199,33 @@ describe('Input', () => { const cases = [ { fontSize: Number(tokens.fontSize.functionalBodySmall), - lineHeight: Number(tokens.lineHeight.functionalBodySmall), + marginVertical: Number(tokens.spacing.componentBase100), minHeight: Number(tokens.lineHeight.functionalBodySmall) + Number(tokens.spacing.componentBase100) * 2, size: 'small', }, { fontSize: Number(tokens.fontSize.functionalBodyMedium), - lineHeight: Number(tokens.lineHeight.functionalBodyMedium), + marginVertical: Number(tokens.spacing.componentBase150), minHeight: Number(tokens.lineHeight.functionalBodyMedium) + Number(tokens.spacing.componentBase150) * 2, size: 'medium', }, { fontSize: Number(tokens.fontSize.functionalBodyLarge), - lineHeight: Number(tokens.lineHeight.functionalBodyLarge), + marginVertical: Number(tokens.spacing.componentBase200), minHeight: Number(tokens.lineHeight.functionalBodyLarge) + Number(tokens.spacing.componentBase200) * 2, size: 'large', }, ] as const; - for (const { fontSize, lineHeight, minHeight, size } of cases) { + for (const { fontSize, marginVertical, minHeight, size } of cases) { const component = await render(); expect(flattenStyle(component.getByRole('textbox').props.style)).toMatchObject({ fontSize, - lineHeight, + marginVertical, + padding: 0, }); + expect(flattenStyle(component.getByRole('textbox').props.style)).not.toHaveProperty('lineHeight'); + expect(flattenStyle(component.getByRole('textbox').props.style)).not.toHaveProperty('paddingVertical'); expect(flattenStyle(getContents(component).props.style)).toMatchObject({ minHeight }); expect(component.queryByTestId('icon-start')).toBeNull(); } @@ -227,4 +243,41 @@ describe('Input', () => { const component = await render(); expect(flattenStyle(component.getByTestId('input-root').props.style)).toMatchObject({ backgroundColor: 'hotpink' }); }); + + it('preserves explicit native editor style overrides after alignment defaults', async () => { + const component = await render(); + + expect(flattenStyle(getTextbox(component).props.style)).toMatchObject({ + lineHeight: 26, + marginVertical: 2, + padding: 0, + paddingVertical: 3, + }); + }); + + it.each([false, true])( + 'focuses the native editor from external spacing with readOnly=%s and preserves the caller ref', + async (readOnly) => { + const ref = React.createRef(); + const component = await render(); + expect(ref.current).not.toBeNull(); + const focus = jest.spyOn(ref.current!, 'focus').mockClear(); + const contents = getContents(component); + + expect(contents.props).toMatchObject({ accessible: false, focusable: false }); + expect(component.queryByRole('button')).toBeNull(); + await fireEvent.press(contents); + expect(focus).toHaveBeenCalledTimes(1); + }, + ); + + it('does not focus a disabled editor through the spacing region', async () => { + const ref = React.createRef(); + const component = await render(); + expect(ref.current).not.toBeNull(); + const focus = jest.spyOn(ref.current!, 'focus').mockClear(); + + await fireEvent.press(getContents(component)); + expect(focus).not.toHaveBeenCalled(); + }); }); diff --git a/packages/agentic/components/src/components/input/input.types.ts b/packages/agentic/components/src/components/input/input.types.ts index e518f51edd..f1a5ed526d 100644 --- a/packages/agentic/components/src/components/input/input.types.ts +++ b/packages/agentic/components/src/components/input/input.types.ts @@ -1,4 +1,4 @@ -import type { AccessibilityState, ColorValue, StyleProp, TextInput, TextStyle, View, ViewStyle } from 'react-native'; +import type { AccessibilityState, ColorValue, Pressable, StyleProp, TextInput, TextStyle, View, ViewStyle } from 'react-native'; import type { ThemeState } from '@fluentui-react-native/design'; import type { @@ -26,7 +26,7 @@ export type InputSlots = { }; type InputStateSlots = InputSlots & { - contents: OptionalSlot; + contents: OptionalSlot; iconTextStack: OptionalSlot; iconEnd: OptionalSlot; underline: OptionalSlot; diff --git a/packages/agentic/components/src/components/input/spec/accessibility.md b/packages/agentic/components/src/components/input/spec/accessibility.md index 47b7527ef9..903cfbd3eb 100644 --- a/packages/agentic/components/src/components/input/spec/accessibility.md +++ b/packages/agentic/components/src/components/input/spec/accessibility.md @@ -59,6 +59,10 @@ belongs in a separate focusable control rather than an icon slot. ## Focus +The contents Pressable is inaccessible and explicitly non-focusable. It only +forwards pointer activation to the editor, so external spacing does not remove +the field's pointer target or add a second keyboard stop. + `focusable` resolves to `true` unless the field is disabled. Input renders no dedicated focus ring; focus is indicated by recoloring the boundary to the heavy neutral stroke at the same thickness used at rest. Verify that contrast in every diff --git a/packages/agentic/components/src/components/input/spec/interaction.md b/packages/agentic/components/src/components/input/spec/interaction.md index 5b0f705d6b..e35ab16e94 100644 --- a/packages/agentic/components/src/components/input/spec/interaction.md +++ b/packages/agentic/components/src/components/input/spec/interaction.md @@ -28,6 +28,15 @@ Hover recolors the boundary to the hovered neutral stroke and press recolors it to the pressed neutral stroke. Neither changes the layout, so the field does not shift under the pointer. Both are suppressed while disabled. +The contents row forwards pointer activation in the external spacing and icon +regions to the native editor, without adding a focus stop or another announced +control. This keeps the field easy to activate after moving vertical spacing +outside the editor. Activation requests focus with pointer intent through the +shared focus target and respects its resolved `focusable` eligibility. Disabled +fields ignore that activation; read-only fields retain focus and copy behavior +by default. Native editing, selection, and caller slot refs remain owned by the +TextInput. + ## Keyboard The platform text input owns caret movement, selection, text composition, diff --git a/packages/agentic/components/src/components/input/spec/source.json b/packages/agentic/components/src/components/input/spec/source.json index 3e2ee53aa2..8591504689 100644 --- a/packages/agentic/components/src/components/input/spec/source.json +++ b/packages/agentic/components/src/components/input/spec/source.json @@ -3,7 +3,7 @@ "component": "input", "lifecycle": "implemented", "conformance": "reviewed", - "reviewedAt": "2026-09-14", + "reviewedAt": "2026-09-21", "sources": [ { "id": "flex-component", diff --git a/packages/agentic/components/src/components/input/spec/tokens.yaml b/packages/agentic/components/src/components/input/spec/tokens.yaml index 9a7508de41..72ba0e748d 100644 --- a/packages/agentic/components/src/components/input/spec/tokens.yaml +++ b/packages/agentic/components/src/components/input/spec/tokens.yaml @@ -97,18 +97,15 @@ bindings: flexGrow: 1 flexShrink: 1 minWidth: 0 - paddingHorizontal: 0 + padding: 0 typography: fontFamily: fontFamily.functional fontSize: small: fontSize.functionalBodySmall medium: fontSize.functionalBodyMedium large: fontSize.functionalBodyLarge - lineHeight: - small: lineHeight.functionalBodySmall - medium: lineHeight.functionalBodyMedium - large: lineHeight.functionalBodyLarge - paddingVertical: + # Native single-line metrics are intrinsic. Line-height tokens size contents.minHeight. + marginVertical: small: spacing.componentBase100 medium: spacing.componentBase150 large: spacing.componentBase200 diff --git a/packages/agentic/components/src/components/input/useInput.ts b/packages/agentic/components/src/components/input/useInput.ts index da8b1fdb9f..5beb418f0d 100644 --- a/packages/agentic/components/src/components/input/useInput.ts +++ b/packages/agentic/components/src/components/input/useInput.ts @@ -1,5 +1,5 @@ import * as React from 'react'; -import { TextInput, View } from 'react-native'; +import { Pressable, TextInput, View } from 'react-native'; import type { TextInputProps } from 'react-native'; import { useThemeState } from '@fluentui-react-native/design'; @@ -85,10 +85,13 @@ export function useInput_unstable(props: InputProps): InputState { textInputProp !== null && typeof textInputProp === 'object' && 'focusable' in textInputProp ? textInputProp.focusable : undefined; const resolvedFocusable = !disabled && (slotFocusable ?? focusable ?? true); const focus = useFocusTarget(resolvedFocusable); - const { focused } = focus; + const { focused, focusTarget } = focus; const blurFocusTarget = focus.onBlur; const [hovered, setHovered] = React.useState(false); const [pressed, setPressed] = React.useState(false); + const focusTextInput = React.useCallback(() => { + focusTarget.requestFocus('pointer'); + }, [focusTarget]); React.useEffect(() => { if (disabled) { @@ -141,7 +144,13 @@ export function useInput_unstable(props: InputProps): InputState { }, []); const root = useSlot(View, rootProps); - const contents = useSlot(View, {}); + const contents = useSlot(Pressable, { + accessibilityRole: 'none', + accessible: false, + disabled, + focusable: false, + onPress: focusTextInput, + }); const iconTextStack = useSlot(View, {}); const iconEnd = useOptionalSlot(View, iconEnd1Prop || iconEnd2Prop ? {} : null); const underline = useOptionalSlot(View, variant === 'underline' ? {} : null); diff --git a/packages/agentic/components/src/components/list-item/list-item.test.tsx b/packages/agentic/components/src/components/list-item/list-item.test.tsx index acf2d9199b..8832b722a7 100644 --- a/packages/agentic/components/src/components/list-item/list-item.test.tsx +++ b/packages/agentic/components/src/components/list-item/list-item.test.tsx @@ -118,7 +118,8 @@ describe('ListItem', () => { expect(component.queryByTestId('regular-icon')).toBeNull(); expect(labels).toHaveLength(2); expect(StyleSheet.flatten(labels[0].props.style)).toMatchObject({ fontWeight: '600', opacity: 0 }); - expect(StyleSheet.flatten(labels[1].props.style)).toMatchObject({ fontWeight: '600', position: 'absolute' }); + expect(StyleSheet.flatten(labels[1].props.style)).toMatchObject({ fontWeight: '600' }); + expect(StyleSheet.flatten(labels[1].parent?.props.style)).toMatchObject({ position: 'absolute', justifyContent: 'center' }); }); it('places secondary content under the primary label when requested', async () => { diff --git a/packages/agentic/components/src/components/skeleton/SPEC.md b/packages/agentic/components/src/components/skeleton/SPEC.md index 0fa594c61b..a7b03c00fd 100644 --- a/packages/agentic/components/src/components/skeleton/SPEC.md +++ b/packages/agentic/components/src/components/skeleton/SPEC.md @@ -52,8 +52,8 @@ root styles. | ------ | -------- | --------------------------------------------------------------------------- | | `root` | yes | The placeholder surface. Clips its content and hosts the highlight overlay. | -The root renders at most one child: an animated overlay that carries the -highlight band. The overlay is mounted only while the sweep can run, is +The root renders at most one child: an animated overlay that carries an SVG +linear-gradient highlight. The overlay is mounted only while the sweep can run, is identified by the `skeleton-shimmer` test id, and takes no pointer input. When the sweep cannot run the root renders no children at all. @@ -73,14 +73,42 @@ caller's `onLayout` first, and run the sweep only once the measured width and height are both greater than zero. **SKEL-004:** Bind the root fill and radius plus the highlight fill to theme -tokens, clip the root, and keep the caller's `style` as the last layer. +tokens, clip the root, and keep the caller's `style` as the last layer. Use the +soft neutral fill for the static silhouette and the subtle neutral fill for +the moving band, so the placeholder does not disappear on a subtle neutral +surface when motion is unavailable. **SKEL-005:** Suppress the sweep and unmount the overlay while the operating system reduced-motion setting is on, with no substitute animation. **SKEL-006:** Size the highlight band relative to the measured width with a -fixed floor and translate it across the full root width on a continuous linear -loop. +fixed floor, fade from transparent to the themed highlight and back to +transparent, and sweep it at a fixed 45-degree angle. Keep that angle independent +of the placeholder's aspect ratio, clip to the caller's silhouette, and fully +clear both edges during the continuous linear loop. Follow reading direction, +including a right-to-left sweep in RTL. + +## Gradient compatibility + +The repository's [Shimmer](../../../../../experimental/Shimmer/src/Shimmer.tsx) +and [Win32 Shimmer](../../../../../experimental/Shimmer/src/Shimmer.win32.tsx) +establish the three-stop linear-gradient wave pattern. Skeleton reuses the +already-supported `react-native-svg` renderer, not the legacy component or its +native animation host. Its existing shared timeline, token colors, public props, +and loading-region accessibility remain authoritative. + +Shimmer permits a caller-selected angle and defaults to zero on desktop. +Skeleton deliberately fixes a diagonal 45-degree wave to satisfy its loading +appearance without adding an angle prop. User-space gradient coordinates and an +overlay widened by the measured height preserve that angle for thin lines, +circles, wide cards, and tall placeholders. Each mounted Skeleton owns a stable +gradient identifier so definitions cannot interfere across instances. + +Native SVG gradient stops do not support opaque `PlatformColor` values. When +the highlight token is opaque, including native high-contrast palettes, Skeleton +retains its static native View with a token-colored outline, omits the sweep, and reports that +limitation in development. It does not substitute another solid moving strip +or replace the caller's platform color with an invented RGB value. ## Platform behavior @@ -89,8 +117,9 @@ and has no tab stop by default, but the broad `ViewProps` surface permits a caller to set `focusable`. The root defaults to `pointerEvents="none"` so an overlay cannot intercept input, while preserving an explicit caller override. -The sweep uses the native driver, so it continues on the platform's animation -thread rather than the JavaScript thread. The reduced-motion source is the +The shared sweep requests the native driver. On macOS Fabric, where native +transform updates remain static, the shared loop uses the JavaScript driver; +other renderers retain native animation. The reduced-motion source is the platform accessibility setting reported by React Native; when it turns on while a placeholder is mounted, the running loop is stopped and the clock is reset. Because the sweep depends on measurement, a placeholder that is never laid out @@ -103,7 +132,9 @@ height, which is the caller's responsibility to avoid. | ID | Disposition | React Native contract | Follow-up | | --------------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | -| `skeleton-highlight-band-fill` | Accepted | FURN paints the sweep as a translating opaque band view held at a fixed opacity. Flex describes a gradient highlight that fades in and out across the bar. | Revisit only if this package takes a gradient dependency. | +| `skeleton-highlight-band-fill` | Resolved | A three-stop SVG linear gradient fades through transparent edges and a themed center, replacing the solid rectangular strip. It sweeps diagonally with aspect-ratio-independent geometry. | Covered by gradient, geometry, identifier, RTL, and native motion checks. | +| `skeleton-static-silhouette` | Accepted | The soft neutral token supplies the base and the subtle neutral token supplies the band. This reverses the previous local assignment, whose base was indistinguishable from the subtle Storybook surface. | Preserve visible static placeholders when reduced motion suppresses the sweep. | +| `skeleton-opaque-gradient-color` | Accepted | Native SVG stops cannot represent opaque platform colors. A static native View uses a token-colored outline and a development warning explains the omitted sweep. | Revisit when the SVG renderer supports native platform-color gradient stops. | | `skeleton-instance-timeline` | Resolved | Active placeholders share one package-level sweep timeline, so instances mounted at different times render at the same phase. | Implemented through the shared animation hook and covered by multi-instance tests. | | `skeleton-container-busy-state` | Not applicable | The root is removed from the accessibility tree and exposes no busy state. Flex assigns the busy semantic to the container that owns the loading region, which in FURN is caller-owned composition rather than part of this component. | None. | | `skeleton-pointer-events` | Resolved | The root defaults to `pointerEvents="none"` so it cannot intercept covered controls, while an explicit caller value is preserved. | Implemented in `useSkeleton.ts` and covered by root-prop tests. | @@ -118,4 +149,4 @@ height, which is the caller's responsibility to avoid. | SKEL-003 | `useSkeleton.ts`, `skeleton.test.tsx` | | SKEL-004 | `skeleton.styles.ts`, `useSkeletonStyles.ts`, `__snapshots__/skeleton.test.tsx.snap`, `skeleton.test.tsx` | | SKEL-005 | `useSkeleton.ts`, `renderSkeleton.tsx`, `skeleton.test.tsx` | -| SKEL-006 | `useSkeleton.ts`, `renderSkeleton.tsx`, `skeleton.stories.tsx` | +| SKEL-006 | `useSkeleton.ts`, `renderSkeleton.tsx`, `skeleton.test.tsx`, `skeleton.stories.tsx` | diff --git a/packages/agentic/components/src/components/skeleton/__snapshots__/skeleton.test.tsx.snap b/packages/agentic/components/src/components/skeleton/__snapshots__/skeleton.test.tsx.snap index 14abde3dcb..ea42617909 100644 --- a/packages/agentic/components/src/components/skeleton/__snapshots__/skeleton.test.tsx.snap +++ b/packages/agentic/components/src/components/skeleton/__snapshots__/skeleton.test.tsx.snap @@ -2,7 +2,7 @@ exports[`Skeleton renders a decorative bar with themed fill and rounded corners 1`] = ` { - "backgroundColor": "#fafafa", + "backgroundColor": "#0000001a", "borderRadius": 2, "height": 16, "overflow": "hidden", diff --git a/packages/agentic/components/src/components/skeleton/renderSkeleton.tsx b/packages/agentic/components/src/components/skeleton/renderSkeleton.tsx index b20218a590..87aac6a24a 100644 --- a/packages/agentic/components/src/components/skeleton/renderSkeleton.tsx +++ b/packages/agentic/components/src/components/skeleton/renderSkeleton.tsx @@ -1,10 +1,11 @@ /** @jsxImportSource @fluentui-react-native/framework-base */ -import { Animated, View } from 'react-native'; +import { Animated } from 'react-native'; +import { Defs, LinearGradient, Rect, Stop, Svg } from 'react-native-svg'; import type { SkeletonState } from './skeleton.types'; export function renderSkeleton_unstable(state: SkeletonState) { - const shouldAnimate = !state.reduceMotion && state.layout.width > 0 && state.layout.height > 0; + const shouldAnimate = !state.reduceMotion && state.shimmerColor !== undefined && state.layout.width > 0 && state.layout.height > 0; return ( @@ -16,11 +17,32 @@ export function renderSkeleton_unstable(state: SkeletonState) { state.shimmerContainerStyle, { transform: [{ translateX: state.translateX }], - width: state.bandWidth, + width: state.shimmerWidth, }, ]} > - + + + + + + + + + + ) : null} diff --git a/packages/agentic/components/src/components/skeleton/skeleton.stories.tsx b/packages/agentic/components/src/components/skeleton/skeleton.stories.tsx index 8136867619..48e16b99af 100644 --- a/packages/agentic/components/src/components/skeleton/skeleton.stories.tsx +++ b/packages/agentic/components/src/components/skeleton/skeleton.stories.tsx @@ -3,7 +3,11 @@ import type { ReactNode } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import type { Meta, StoryObj } from '@storybook/react-native'; +import { useReducedMotion } from '@fluentui-react-native/framework-base'; +import { useThemeState } from '@fluentui-react-native/design'; +import type { WdioStory } from '@fluentui-react-native/storybook-desktop/testing'; +import { StoryStatus } from '../../common/StoryStatus.story-helpers'; import { Skeleton } from './skeleton'; type StoryGroupProps = { @@ -51,7 +55,68 @@ const meta: Meta = { export default meta; -type Story = StoryObj; +type Story = WdioStory>; + +function MotionExample() { + const reduceMotion = useReducedMotion(); + const highlightColor: unknown = useThemeState().tokens.color.backgroundNeutralSubtle; + return ( + + + + + + {reduceMotion === undefined + ? 'Motion preference pending' + : reduceMotion + ? 'Reduced motion enabled' + : typeof highlightColor !== 'string' + ? 'Gradient unavailable for native platform colors' + : 'Motion enabled'} + + + ); +} + +export const Motion: Story = { + render: () => , + parameters: { + docs: { + description: { + story: 'A soft, angled linear-gradient wave sweeps across the static silhouette and follows the native reduced-motion preference.', + }, + }, + }, + wdio: { + 'renders changing native pixels when gradient animation is available': async ({ browser, expect, skip }) => { + const features = browser.capabilities['furn:features']; + if (!features) throw new Error('Desktop Driver did not provide feature capabilities.'); + if (!features.elementScreenshot) { + skip('This test requires native element screenshots.'); + return; + } + const preference = await browser.$('~skeleton-motion-preference'); + await browser.waitUntil(async () => + ['Motion enabled', 'Reduced motion enabled', 'Gradient unavailable for native platform colors'].includes( + await preference.getText(), + ), + ); + const skeleton = await browser.$('~skeleton-motion'); + await expect(skeleton).toExist(); + expect(await browser.isElementDisplayed(await skeleton.elementId)).toBe(true); + const frames = new Set(); + for (let index = 0; index < 5; index++) { + frames.add(await browser.takeElementScreenshot(await skeleton.elementId)); + await browser.pause(117); + } + if ((await preference.getText()) === 'Motion enabled') { + expect(frames.size).toBeGreaterThan(1); + } else { + expect(frames.size).toBe(1); + } + }, + }, +}; export const Default: Story = { args: { @@ -83,6 +148,9 @@ export const Overview: Story = { + + + ), @@ -169,6 +237,11 @@ const styles = StyleSheet.create({ height: 12, width: 120, }, + tall: { + borderRadius: 4, + height: 120, + width: 32, + }, thumbnail: { borderRadius: 10, height: 72, diff --git a/packages/agentic/components/src/components/skeleton/skeleton.styles.ts b/packages/agentic/components/src/components/skeleton/skeleton.styles.ts index 4b33637814..7ec3b201c6 100644 --- a/packages/agentic/components/src/components/skeleton/skeleton.styles.ts +++ b/packages/agentic/components/src/components/skeleton/skeleton.styles.ts @@ -5,7 +5,6 @@ import { themedStyleSheetFactory, type ThemeState } from '@fluentui-react-native export type SkeletonThemeStyles = { root: ViewStyle; - shimmerBand: ViewStyle; }; export const skeletonStyles = StyleSheet.create({ @@ -21,21 +20,15 @@ export const skeletonStyles = StyleSheet.create({ position: 'absolute', top: 0, }, - shimmerBand: { - height: '100%', - opacity: 0.64, - }, }); -export const getSkeletonThemeStyles = themedStyleSheetFactory('Skeleton', ({ tokens }: ThemeState) => - StyleSheet.create({ +export const getSkeletonThemeStyles = themedStyleSheetFactory('Skeleton', ({ tokens }: ThemeState) => { + const opaqueHighlight = typeof tokens.color.backgroundNeutralSubtle !== 'string'; + return StyleSheet.create({ root: { - backgroundColor: tokens.color.backgroundNeutralSubtle, - borderRadius: tokens.borderRadius.base100, - }, - shimmerBand: { backgroundColor: tokens.color.backgroundNeutralSoft, borderRadius: tokens.borderRadius.base100, + ...(opaqueHighlight ? { borderColor: tokens.color.strokeNeutralLoud, borderWidth: Number(tokens.strokeWidth.thin) } : {}), }, - }), -); + }); +}); diff --git a/packages/agentic/components/src/components/skeleton/skeleton.test.tsx b/packages/agentic/components/src/components/skeleton/skeleton.test.tsx index 371f0bff24..a393980e33 100644 --- a/packages/agentic/components/src/components/skeleton/skeleton.test.tsx +++ b/packages/agentic/components/src/components/skeleton/skeleton.test.tsx @@ -1,10 +1,12 @@ /** @jsxImportSource @fluentui-react-native/framework-base */ import { act } from 'react'; -import { Animated, StyleSheet } from 'react-native'; +import { Animated, I18nManager, PlatformColor, StyleSheet, processColor } from 'react-native'; import type { ViewStyle } from 'react-native'; import { fireEvent } from '@testing-library/react-native'; import { render } from '../../common/renderWithTheme'; +import { defaultFlexTokens, mockTheme } from '@fluentui-react-native/design/testing'; +import { ThemeProvider, ThemeReference } from '@fluentui-react-native/design/theming'; import { useReducedMotion } from '@fluentui-react-native/framework-base'; @@ -20,6 +22,7 @@ const mockUseReducedMotion = jest.mocked(useReducedMotion); describe('Skeleton', () => { beforeEach(() => { mockUseReducedMotion.mockReturnValue(false); + jest.spyOn(Animated, 'loop').mockReturnValue({ start: jest.fn(), stop: jest.fn(), reset: jest.fn() }); }); afterEach(() => { @@ -38,6 +41,14 @@ describe('Skeleton', () => { return StyleSheet.flatten(getRoot(component).props.style); } + function getNativeNode(component: Awaited>, type: string, testID = 'skeleton') { + const node = component.getByTestId(testID, { includeHiddenElements: true }).queryAll((instance) => instance.type === type)[0]; + if (!node) { + throw new Error(`Skeleton must render ${type}.`); + } + return node; + } + it('renders a decorative bar with themed fill and rounded corners', async () => { const component = await renderSkeleton({ style: { height: 16, width: 120 } }); @@ -71,6 +82,81 @@ describe('Skeleton', () => { expect(getRootStyle(component).backgroundColor).toBe('hotpink'); }); + it.each([ + { width: 176, height: 12 }, + { width: 48, height: 48 }, + { width: 224, height: 96 }, + { width: 12, height: 176 }, + ])('renders a soft 45-degree gradient at $width by $height', async ({ width, height }) => { + const component = await renderSkeleton({ style: { width, height } }); + await fireEvent(getRoot(component), 'layout', { nativeEvent: { layout: { width, height, x: 0, y: 0 } } }); + const gradient = getNativeNode(component, 'RNSVGLinearGradient'); + const bandWidth = Math.max(Math.round(width * 0.45), 24); + + expect(gradient.props).toMatchObject({ + gradientUnits: 1, + x1: height, + y1: 0, + x2: height + bandWidth / 2, + y2: bandWidth / 2, + }); + expect(gradient.props.x2 - gradient.props.x1).toBe(gradient.props.y2 - gradient.props.y1); + const color = processColor(defaultFlexTokens.color.backgroundNeutralSubtle); + if (typeof color !== 'number') { + throw new Error('The test theme must supply a concrete SVG highlight color.'); + } + const rgb = color & 0x00ffffff; + expect(gradient.props.gradient).toEqual([0, rgb, 0.5, rgb | (Math.round(0.64 * 255) << 24), 1, rgb]); + expect(getNativeNode(component, 'RNSVGSvgView').props).toMatchObject({ + bbWidth: bandWidth + height, + bbHeight: height, + minX: 0, + minY: 0, + vbWidth: bandWidth + height, + vbHeight: height, + }); + expect(getNativeNode(component, 'RNSVGRect').props).toMatchObject({ + fill: { type: 1, brushRef: gradient.props.name }, + width: bandWidth + height, + height, + }); + expect(getRootStyle(component).overflow).toBe('hidden'); + }); + + it.each([false, true])('clears the whole angled band at both loop ends with RTL=%s', async (isRTL) => { + jest.replaceProperty(I18nManager, 'isRTL', isRTL); + const interpolate = jest.spyOn(Animated.Value.prototype, 'interpolate'); + const component = await renderSkeleton({ style: { width: 120, height: 96 } }); + await fireEvent(getRoot(component), 'layout', { nativeEvent: { layout: { width: 120, height: 96, x: 0, y: 0 } } }); + + expect(interpolate).toHaveBeenLastCalledWith({ + inputRange: [0, 1], + outputRange: isRTL ? [120, -150] : [-150, 120], + }); + }); + + it('keeps gradient definitions unique across instances and stable across resizing', async () => { + const component = await render( + <> + + + , + ); + for (const testID of ['first', 'second']) { + await fireEvent(component.getByTestId(testID, { includeHiddenElements: true }), 'layout', { + nativeEvent: { layout: { width: 120, height: 16, x: 0, y: 0 } }, + }); + } + const firstId = getNativeNode(component, 'RNSVGLinearGradient', 'first').props.name; + expect(firstId).not.toBe(getNativeNode(component, 'RNSVGLinearGradient', 'second').props.name); + + await fireEvent(component.getByTestId('first', { includeHiddenElements: true }), 'layout', { + nativeEvent: { layout: { width: 224, height: 96, x: 0, y: 0 } }, + }); + expect(getNativeNode(component, 'RNSVGLinearGradient', 'first').props.name).toBe(firstId); + expect(getNativeNode(component, 'RNSVGSvgView', 'first').props).toMatchObject({ bbHeight: 96, bbWidth: 197 }); + }); + it('hides the shimmer when reduce motion is enabled', async () => { mockUseReducedMotion.mockReturnValue(true); const component = await renderSkeleton({ style: { height: 16, width: 120 } }); @@ -81,17 +167,43 @@ describe('Skeleton', () => { }); expect(component.queryByTestId('skeleton-shimmer', { includeHiddenElements: true })).toBeNull(); + expect(getRootStyle(component).backgroundColor).toBe(defaultFlexTokens.color.backgroundNeutralSoft); + expect(getRootStyle(component).backgroundColor).not.toBe(defaultFlexTokens.color.backgroundNeutralSubtle); }); - it('shares one animation timeline across mounted placeholders', async () => { - const loop = jest.spyOn(Animated, 'loop').mockReturnValue({ start: jest.fn(), stop: jest.fn() } as never); - const first = await renderSkeleton({ style: { height: 16, width: 120 } }); - const second = await renderSkeleton({ style: { height: 16, width: 120 } }); + it('reports unsupported opaque gradient colors and retains a native outlined placeholder', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(); + const theme = new ThemeReference(mockTheme, { colors: { neutralBackground2: PlatformColor('windowBackgroundColor') } }); + const component = await render( + + + , + ); + await fireEvent(getRoot(component), 'layout', { nativeEvent: { layout: { width: 120, height: 16, x: 0, y: 0 } } }); - await act(async () => { - await fireEvent(getRoot(first), 'layout', { nativeEvent: { layout: { height: 16, width: 120, x: 0, y: 0 } } }); - await fireEvent(getRoot(second), 'layout', { nativeEvent: { layout: { height: 16, width: 120, x: 0, y: 0 } } }); - }); + expect(component.queryByTestId('skeleton-shimmer', { includeHiddenElements: true })).toBeNull(); + expect(Animated.loop).not.toHaveBeenCalled(); + expect(getRootStyle(component).borderWidth).toBeGreaterThan(0); + expect(getRootStyle(component).borderColor).toBeDefined(); + expect(warn).toHaveBeenCalledWith( + 'Skeleton: rendering a static placeholder because native SVG gradients cannot use an opaque highlight color.', + ); + }); + + it('shares one animation timeline across mounted placeholders', async () => { + const loop = jest.mocked(Animated.loop); + const component = await render( + <> + + + , + ); + + for (const testID of ['first', 'second']) { + await fireEvent(component.getByTestId(testID, { includeHiddenElements: true }), 'layout', { + nativeEvent: { layout: { width: 120, height: 16, x: 0, y: 0 } }, + }); + } expect(loop).toHaveBeenCalledTimes(1); }); diff --git a/packages/agentic/components/src/components/skeleton/skeleton.types.ts b/packages/agentic/components/src/components/skeleton/skeleton.types.ts index e41c254891..a34c3ab365 100644 --- a/packages/agentic/components/src/components/skeleton/skeleton.types.ts +++ b/packages/agentic/components/src/components/skeleton/skeleton.types.ts @@ -21,9 +21,11 @@ export type SkeletonState = ComponentState & reduceMotion: boolean; userStyle?: StyleProp; bandWidth: number; + gradientId: string; + shimmerColor: string | undefined; + shimmerWidth: number; translateX: Animated.AnimatedInterpolation; shimmerContainerStyle: StyleProp; - shimmerBandStyle: StyleProp; onLayout: (event: LayoutChangeEvent) => void; progress: Animated.Value; }; diff --git a/packages/agentic/components/src/components/skeleton/spec/interaction.md b/packages/agentic/components/src/components/skeleton/spec/interaction.md index e3b6ccdad7..1ba7330c8d 100644 --- a/packages/agentic/components/src/components/skeleton/spec/interaction.md +++ b/packages/agentic/components/src/components/skeleton/spec/interaction.md @@ -21,16 +21,29 @@ placeholder with no measured size renders as a static themed block. ## Sweep -The sweep is a highlight band that travels across the placeholder on a -continuous linear loop. The band is a fraction of the measured width with a -fixed minimum, and it translates from just before the leading edge to just past -the trailing edge, so the band is fully off the placeholder at both ends of a -cycle. The loop repeats without pausing for as long as the placeholder is -mounted. - -The animation runs through `Animated` with the native driver, so it is not -affected by JavaScript thread work. Active placeholders subscribe to one shared -sweep channel, so instances mounted at different times read the same phase. +The sweep is a three-stop linear gradient: transparent at both ends and the +themed highlight at the center, with peak opacity 0.64. It travels left to right +in LTR and right to left in RTL, with a fixed diagonal 45-degree band. The +horizontal band width is 45 percent of the measured root width, rounded to a +whole layout unit, with a minimum of 24. + +The gradient uses user-space coordinates rather than a stretched bounding-box +gradient. Its viewport width is the band width plus the root height, which +contains the complete diagonal at every row without hard side cuts. Translation +runs between minus that viewport width and the root width, reversed in RTL, so +the highlight is fully outside the clipped root at both ends of the loop. +Resizing recomputes the geometry without creating a separate animation clock. + +The animation runs through the shared `Animated` loop. It uses the native +driver except on macOS Fabric, whose native transform updates remain static; +that renderer uses the JavaScript driver. Active placeholders subscribe to one +shared sweep channel, so instances mounted at different times read the same +phase. + +An opaque highlight color, such as a native high-contrast `PlatformColor`, +cannot be passed to SVG gradient stops. In that case no loop starts and the +placeholder remains static with a token-colored outline. Development builds warn about this renderer +limitation instead of silently substituting a solid moving band. ## Motion and lifecycle diff --git a/packages/agentic/components/src/components/skeleton/spec/source.json b/packages/agentic/components/src/components/skeleton/spec/source.json index d83a90357f..5858a8a24d 100644 --- a/packages/agentic/components/src/components/skeleton/spec/source.json +++ b/packages/agentic/components/src/components/skeleton/spec/source.json @@ -3,7 +3,7 @@ "component": "skeleton", "lifecycle": "implemented", "conformance": "reviewed", - "reviewedAt": "2026-08-28", + "reviewedAt": "2026-09-22", "sources": [ { "id": "flex-component", @@ -67,13 +67,45 @@ } ], "releaseDifferences": [] + }, + { + "id": "legacy-shimmer", + "kind": "git-files", + "authority": "compatibility-reference", + "repository": "microsoft/fluentui-react-native", + "commit": "50e398c28ee632a9ce5b6835f259c81d99386d96", + "files": [ + { + "role": "implementation", + "path": "packages/experimental/Shimmer/src/Shimmer.tsx", + "sha256": "16033f2e9895378dcc2f381180445d1044ef1d4646be5a8cbd51a8aa9d129a80" + }, + { + "role": "types", + "path": "packages/experimental/Shimmer/src/Shimmer.types.shared.ts", + "sha256": "c4beecaa90a58049d98913151ed358709069d3ebed1378df16b6c03242576ce1" + }, + { + "role": "win32:implementation", + "path": "packages/experimental/Shimmer/src/Shimmer.win32.tsx", + "sha256": "acdd81c1f983122b6cbc552d3a63d8c8991b7081e4ec41f2b24b018160a6111d" + } + ] } ], "divergences": [ { - "id": "skeleton-highlight-band-fill", + "id": "skeleton-opaque-gradient-color", + "status": "accepted" + }, + { + "id": "skeleton-static-silhouette", "status": "accepted" }, + { + "id": "skeleton-highlight-band-fill", + "status": "resolved" + }, { "id": "skeleton-instance-timeline", "status": "resolved" @@ -94,27 +126,33 @@ "requirements": [ { "id": "SKEL-001", + "sources": ["flex-component"], "evidence": ["skeleton.types.ts", "useSkeleton.ts", "skeleton.types.test.ts", "skeleton.test.tsx"] }, { "id": "SKEL-002", + "sources": ["flex-component", "legacy-shimmer"], "evidence": ["renderSkeleton.tsx", "skeleton.test.tsx"] }, { "id": "SKEL-003", + "sources": ["flex-component"], "evidence": ["useSkeleton.ts", "skeleton.test.tsx"] }, { "id": "SKEL-004", + "sources": ["flex-component"], "evidence": ["skeleton.styles.ts", "useSkeletonStyles.ts", "__snapshots__/skeleton.test.tsx.snap", "skeleton.test.tsx"] }, { "id": "SKEL-005", + "sources": ["flex-component"], "evidence": ["useSkeleton.ts", "renderSkeleton.tsx", "skeleton.test.tsx"] }, { "id": "SKEL-006", - "evidence": ["useSkeleton.ts", "renderSkeleton.tsx", "skeleton.stories.tsx"] + "sources": ["flex-component", "legacy-shimmer"], + "evidence": ["useSkeleton.ts", "renderSkeleton.tsx", "skeleton.test.tsx", "skeleton.stories.tsx"] } ] } diff --git a/packages/agentic/components/src/components/skeleton/spec/tokens.yaml b/packages/agentic/components/src/components/skeleton/spec/tokens.yaml index c59d949e9c..1e2f592b4b 100644 --- a/packages/agentic/components/src/components/skeleton/spec/tokens.yaml +++ b/packages/agentic/components/src/components/skeleton/spec/tokens.yaml @@ -13,8 +13,11 @@ bindings: overflow: hidden position: relative theme: - backgroundColor: color.backgroundNeutralSubtle + backgroundColor: color.backgroundNeutralSoft borderRadius: borderRadius.base100 + opaqueHighlight: + borderColor: color.strokeNeutralLoud + borderWidth: strokeWidth.thin userStyle: applied last by useSkeletonStyles_unstable shimmerContainer: @@ -26,33 +29,46 @@ bindings: overflow: hidden pointerEvents: none instance: - width: band width derived from the measured root width - transform: translateX from minus the band width to the measured width plus the band width + width: band width plus the measured root height + transform: translateX from minus the overlay width to the measured root width, reversed in RTL - shimmerBand: - static: - height: 100% - opacity: 0.64 + shimmerGradient: theme: - backgroundColor: color.backgroundNeutralSoft - borderRadius: borderRadius.base100 + stopColor: color.backgroundNeutralSubtle + geometry: + gradientUnits: userSpaceOnUse + x1: measured root height + y1: 0 + x2: measured root height plus half the band width + y2: half the band width + stops: + - offset: 0 + opacity: 0 + - offset: 0.5 + opacity: 0.64 + - offset: 1 + opacity: 0 motion: implementation: useSkeleton.ts - driver: Animated.loop over Animated.timing with useNativeDriver true + driver: shared Animated.loop requests native animation; macOS Fabric uses the JavaScript driver duration: 1700 easing: linear - gate: measured width and height are both greater than zero and reduced motion is off + gate: measured width and height are both greater than zero, reduced motion is off, and the highlight is a concrete color string tokenGaps: - id: skeleton-band-opacity - binding: shimmerBand.opacity + binding: shimmerGradient.stops value: 0.64 reason: The highlight lift is a literal opacity in styling code because no theme token covers it. - id: skeleton-band-width binding: shimmerContainer.width value: 45 percent of the measured width with a floor of 24 reason: The band proportion and its minimum are literals in useSkeleton.ts with no token coverage. + - id: skeleton-gradient-angle + binding: shimmerGradient.geometry + value: 45 degrees + reason: A fixed diagonal wave satisfies the local loading contract without adding Shimmer's configurable angle API. - id: skeleton-sweep-duration binding: motion.duration value: 1700 diff --git a/packages/agentic/components/src/components/skeleton/spec/usage.md b/packages/agentic/components/src/components/skeleton/spec/usage.md index ed96345675..1cee1cf77d 100644 --- a/packages/agentic/components/src/components/skeleton/spec/usage.md +++ b/packages/agentic/components/src/components/skeleton/spec/usage.md @@ -50,6 +50,10 @@ that has no bounded end. ## Motion +The highlight is a soft diagonal linear-gradient wave, not a solid sliding +rectangle. It follows reading direction and keeps the same angle across +different placeholder aspect ratios. + The sweep stops entirely under the platform reduced-motion setting, so never rely on movement alone to communicate that a region is busy. Active placeholders share one phase even when they mount at different moments. diff --git a/packages/agentic/components/src/components/skeleton/useSkeleton.ts b/packages/agentic/components/src/components/skeleton/useSkeleton.ts index 392a43b04a..149f93f862 100644 --- a/packages/agentic/components/src/components/skeleton/useSkeleton.ts +++ b/packages/agentic/components/src/components/skeleton/useSkeleton.ts @@ -1,12 +1,12 @@ import * as React from 'react'; -import { View } from 'react-native'; +import { I18nManager, View } from 'react-native'; import type { LayoutRectangle } from 'react-native'; import { useThemeState } from '@fluentui-react-native/design'; import { useReducedMotion, useSharedAnimatedLoop, useSlot } from '@fluentui-react-native/framework-base'; import { hiddenFromAccessibilityProps } from '../../common/accessibility'; -import { skeletonStyles, getSkeletonThemeStyles } from './skeleton.styles'; +import { skeletonStyles } from './skeleton.styles'; import type { SkeletonProps, SkeletonState } from './skeleton.types'; const animationDuration = 1700; @@ -18,15 +18,23 @@ function createEmptyLayout(): LayoutRectangle { export function useSkeleton_unstable(props: SkeletonProps): SkeletonState { const { onLayout, style: userStyle, ...rest } = props; const themeState = useThemeState(); + const gradientId = `skeleton-gradient-${React.useId().replace(/:/g, '')}`; + const highlightColor: unknown = themeState.tokens.color.backgroundNeutralSubtle; + const shimmerColor = typeof highlightColor === 'string' ? highlightColor : undefined; const [layout, setLayout] = React.useState(createEmptyLayout); const reduceMotion = useReducedMotion() ?? false; const progress = useSharedAnimatedLoop({ channel: 'agentic-components:skeleton', duration: animationDuration, - enabled: !reduceMotion && layout.width > 0 && layout.height > 0, + enabled: !reduceMotion && shimmerColor !== undefined && layout.width > 0 && layout.height > 0, useNativeDriver: true, }); - const themeStyles = getSkeletonThemeStyles(themeState); + + React.useEffect(() => { + if (__DEV__ && shimmerColor === undefined) { + console.warn('Skeleton: rendering a static placeholder because native SVG gradients cannot use an opaque highlight color.'); + } + }, [shimmerColor]); const handleLayout = React.useCallback( (event: Parameters>[0]) => { @@ -37,13 +45,16 @@ export function useSkeleton_unstable(props: SkeletonProps): SkeletonState { ); const bandWidth = Math.max(Math.round(layout.width * 0.45), 24); + // A 45-degree band spans its horizontal width plus the full placeholder height. + const shimmerWidth = bandWidth + layout.height; + const isRTL = I18nManager.isRTL; const translateX = React.useMemo( () => progress.interpolate({ inputRange: [0, 1], - outputRange: [-bandWidth, layout.width + bandWidth], + outputRange: isRTL ? [layout.width, -shimmerWidth] : [-shimmerWidth, layout.width], }), - [bandWidth, layout.width, progress], + [isRTL, layout.width, progress, shimmerWidth], ); const root = useSlot(View, { @@ -60,9 +71,11 @@ export function useSkeleton_unstable(props: SkeletonProps): SkeletonState { reduceMotion, userStyle, bandWidth, + gradientId, + shimmerColor, + shimmerWidth, translateX, shimmerContainerStyle: skeletonStyles.shimmerContainer, - shimmerBandStyle: [skeletonStyles.shimmerBand, themeStyles.shimmerBand], onLayout: handleLayout, progress, }; diff --git a/packages/agentic/components/src/components/spinner/SPEC.md b/packages/agentic/components/src/components/spinner/SPEC.md index 5a0ad3d13b..1b71e4cdb0 100644 --- a/packages/agentic/components/src/components/spinner/SPEC.md +++ b/packages/agentic/components/src/components/spinner/SPEC.md @@ -65,7 +65,8 @@ public slot; there is no way to replace either circle. **SPIN-003:** Draw the track first and the indicator second inside a non-accessible drawing surface, and keep the visible arc at one quarter of the -circumference at every size by normalizing the dash pattern to the path length. +circumference at every size by deriving both dash lengths from the measured +radius. Do not rely on the web-only SVG `pathLength` normalization. **SPIN-004:** Bind the track and indicator colors and the per-size stroke widths to theme tokens, and apply the caller's `style` last. @@ -97,7 +98,9 @@ progress element. Narrator and VoiceOver read the accessible name and the busy state when the element is encountered, and neither platform re-announces the spinner while it rotates. -The rotation runs on the native driver. The reduced-motion setting is read +The shared rotation requests the native driver. macOS Fabric uses the +JavaScript driver because native transform updates remain static on that +renderer; other renderers retain native animation. The reduced-motion setting is read asynchronously from the platform, so the first render can occur before the value is known; the component holds the arc static until it resolves and starts the loop only when reduced motion is known to be off. diff --git a/packages/agentic/components/src/components/spinner/renderSpinner.tsx b/packages/agentic/components/src/components/spinner/renderSpinner.tsx index 304a5f54f8..58b6ace6fe 100644 --- a/packages/agentic/components/src/components/spinner/renderSpinner.tsx +++ b/packages/agentic/components/src/components/spinner/renderSpinner.tsx @@ -7,7 +7,7 @@ import type { SpinnerState } from './spinner.types'; * Renders the Spinner component. */ export function renderSpinner_unstable(state: SpinnerState) { - const indicatorPathProps = { pathLength: 100 } as any; + const circumference = 2 * Math.PI * state.radius; return ( @@ -28,10 +28,9 @@ export function renderSpinner_unstable(state: SpinnerState) { cy={state.center} fill="none" testID="spinner-indicator" - {...indicatorPathProps} r={state.radius} stroke={state.indicatorColor} - strokeDasharray="25 75" + strokeDasharray={[circumference / 4, (circumference * 3) / 4]} strokeLinecap="round" strokeWidth={state.strokeWidth} vectorEffect="non-scaling-stroke" diff --git a/packages/agentic/components/src/components/spinner/spec/interaction.md b/packages/agentic/components/src/components/spinner/spec/interaction.md index df15895337..cd791c05ee 100644 --- a/packages/agentic/components/src/components/spinner/spec/interaction.md +++ b/packages/agentic/components/src/components/spinner/spec/interaction.md @@ -13,17 +13,19 @@ it is never focusable. ## Rotation The drawing surface rotates a full turn on a continuous loop: `Animated.timing` -over 1500 milliseconds with linear easing, wrapped in `Animated.loop` and run on -the native driver so the rotation is unaffected by work on the JavaScript -thread. The rotated value is interpolated from zero to a full turn, so the +over 1500 milliseconds with linear easing, wrapped in the shared `Animated.loop`. +The native driver is used except on macOS Fabric, whose native transform updates +remain static; that renderer uses the JavaScript driver. The rotated value is +interpolated from zero to a full turn, so the wrap-around from the end of one cycle to the start of the next is seamless and there is no pause at the top. Only the surface rotates. The track is a full circle and is visually unchanged by the rotation; the indicator is a quarter-circumference arc, so the apparent motion is the arc sweeping around a static ring. The arc keeps that same -proportion at every size because the dash pattern is expressed against a -normalized path length rather than in absolute units. +proportion at every size because the visible and gap lengths are calculated +as one quarter and three quarters of `2 * Math.PI * radius`. React Native SVG +does not implement the web `pathLength` normalization. ## Reduced motion diff --git a/packages/agentic/components/src/components/spinner/spec/source.json b/packages/agentic/components/src/components/spinner/spec/source.json index 5af72701f7..4e7530d97a 100644 --- a/packages/agentic/components/src/components/spinner/spec/source.json +++ b/packages/agentic/components/src/components/spinner/spec/source.json @@ -3,7 +3,7 @@ "component": "spinner", "lifecycle": "implemented", "conformance": "reviewed", - "reviewedAt": "2026-08-28", + "reviewedAt": "2026-09-21", "sources": [ { "id": "flex-component", diff --git a/packages/agentic/components/src/components/spinner/spec/tokens.yaml b/packages/agentic/components/src/components/spinner/spec/tokens.yaml index 6d7e585e0f..66804a20b1 100644 --- a/packages/agentic/components/src/components/spinner/spec/tokens.yaml +++ b/packages/agentic/components/src/components/spinner/spec/tokens.yaml @@ -40,8 +40,7 @@ bindings: strokeWidth: resolved size stroke width token strokeLinecap: round fill: none - pathLength: 100 - strokeDasharray: 25 visible units and 75 gap units of the normalized path length + strokeDasharray: one quarter of the circumference visible and three quarters gap geometry: implementation: getSpinnerMetrics in spinner.styles.ts @@ -76,7 +75,7 @@ size: motion: implementation: useSpinner.ts - driver: Animated.loop over Animated.timing with useNativeDriver true + driver: shared Animated.loop requests native animation; macOS Fabric uses the JavaScript driver duration: 1500 easing: linear @@ -87,7 +86,7 @@ tokenGaps: reason: Diameters are intrinsic to the component and have no coverage in the spacing or sizing scales. - id: spinner-arc-fraction binding: indicator.strokeDasharray - value: one quarter of the normalized path length + value: one quarter of the circumference reason: The visible arc fraction is a design constant expressed directly in the render code. - id: spinner-rotation-duration binding: motion.duration diff --git a/packages/agentic/components/src/components/spinner/spinner.stories.tsx b/packages/agentic/components/src/components/spinner/spinner.stories.tsx index e8d820b513..8333075f7f 100644 --- a/packages/agentic/components/src/components/spinner/spinner.stories.tsx +++ b/packages/agentic/components/src/components/spinner/spinner.stories.tsx @@ -3,7 +3,10 @@ import type { ReactNode } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import type { Meta, StoryObj } from '@storybook/react-native'; +import { useReducedMotion } from '@fluentui-react-native/framework-base'; +import type { WdioStory } from '@fluentui-react-native/storybook-desktop/testing'; +import { StoryStatus } from '../../common/StoryStatus.story-helpers'; import { Spinner } from './spinner'; import type { SpinnerSize } from './spinner.types'; @@ -64,10 +67,59 @@ const meta: Meta = { export default meta; -type Story = StoryObj; +type Story = WdioStory>; export const Default: Story = {}; +function MotionExample() { + const reduceMotion = useReducedMotion(); + return ( + + + + {reduceMotion === undefined ? 'Motion preference pending' : reduceMotion ? 'Reduced motion enabled' : 'Motion enabled'} + + + ); +} + +export const Motion: Story = { + render: () => , + parameters: { + docs: { + description: { + story: + 'The arc rotates unless the native reduced-motion preference is enabled. The status reports that preference without changing it.', + }, + }, + }, + wdio: { + 'renders changing native pixels unless reduced motion is enabled': async ({ browser, expect, skip }) => { + const features = browser.capabilities['furn:features']; + if (!features) throw new Error('Desktop Driver did not provide feature capabilities.'); + if (!features.elementScreenshot) { + skip('This test requires native element screenshots.'); + return; + } + const preference = await browser.$('~spinner-motion-preference'); + await browser.waitUntil(async () => ['Motion enabled', 'Reduced motion enabled'].includes(await preference.getText())); + const spinner = await browser.$('~spinner-motion'); + await expect(spinner).toExist(); + expect(await browser.isElementDisplayed(await spinner.elementId)).toBe(true); + const frames = new Set(); + for (let index = 0; index < 5; index++) { + frames.add(await browser.takeElementScreenshot(await spinner.elementId)); + await browser.pause(117); + } + if ((await preference.getText()) === 'Motion enabled') { + expect(frames.size).toBeGreaterThan(1); + } else { + expect(frames.size).toBe(1); + } + }, + }, +}; + export const Overview: Story = { render: () => ( diff --git a/packages/agentic/components/src/components/spinner/spinner.test.tsx b/packages/agentic/components/src/components/spinner/spinner.test.tsx index 15577daaad..bbba2d1b9b 100644 --- a/packages/agentic/components/src/components/spinner/spinner.test.tsx +++ b/packages/agentic/components/src/components/spinner/spinner.test.tsx @@ -72,7 +72,9 @@ describe('Spinner', () => { expect(track.props.stroke).toEqual(normalizeColor(tokens.color.strokeNeutralSubtle)); expect(track.props.strokeWidth).toBe(tokens.strokeWidth.thick); expect(indicator.props.stroke).toEqual(normalizeColor(tokens.color.strokeNeutralLoud)); - expect(indicator.props.strokeDasharray).toEqual(['25', '75']); + const circumference = 2 * Math.PI * ((32 - Number(tokens.strokeWidth.thick)) / 2); + expect(indicator.props.strokeDasharray).toEqual([circumference / 4, (circumference * 3) / 4]); + expect(indicator.props).not.toHaveProperty('pathLength'); expect(indicator.props.strokeWidth).toBe(tokens.strokeWidth.thick); }); @@ -97,6 +99,9 @@ describe('Spinner', () => { expect(getSvg(component).props.height).toBe(diameter); expect(track.props.strokeWidth).toBe(tokens.strokeWidth[strokeWidthToken]); expect(indicator.props.strokeWidth).toBe(tokens.strokeWidth[strokeWidthToken]); + const circumference = Math.PI * (diameter - Number(tokens.strokeWidth[strokeWidthToken])); + expect(indicator.props.strokeDasharray[0]).toBeCloseTo(circumference / 4); + expect(indicator.props.strokeDasharray[1]).toBeCloseTo((circumference * 3) / 4); }); it('warns when no accessible name is supplied', async () => { @@ -111,7 +116,7 @@ describe('Spinner', () => { warn.mockRestore(); }); - it('shares continuous rotation when reduce motion is disabled', async () => { + it('shares continuous rotation through the macOS Fabric driver when reduce motion is disabled', async () => { const loop = jest.spyOn(Animated, 'loop').mockReturnValue({ start: jest.fn(), stop: jest.fn() } as never); const timing = jest.spyOn(Animated, 'timing').mockReturnValue({ start: jest.fn(), stop: jest.fn() } as never); jest.spyOn(AccessibilityInfo, 'isReduceMotionEnabled').mockResolvedValue(false); @@ -128,7 +133,7 @@ describe('Spinner', () => { duration: 1500, easing: Easing.linear, toValue: 1, - useNativeDriver: true, + useNativeDriver: false, }), ); }); diff --git a/packages/agentic/components/src/components/tab/tab.test.tsx b/packages/agentic/components/src/components/tab/tab.test.tsx index bf1713387b..e8e921f1dc 100644 --- a/packages/agentic/components/src/components/tab/tab.test.tsx +++ b/packages/agentic/components/src/components/tab/tab.test.tsx @@ -229,8 +229,8 @@ describe('Tab', () => { expect(StyleSheet.flatten(labels[0].props.style)).toMatchObject({ fontWeight: tokens.fontWeight.functionalSemibold, opacity: 0 }); expect(StyleSheet.flatten(labels[1].props.style)).toMatchObject({ fontWeight: tokens.fontWeight.functionalRegular, - position: 'absolute', }); + expect(StyleSheet.flatten(labels[1].parent?.props.style)).toMatchObject({ position: 'absolute', justifyContent: 'center' }); expect(getRootStyle(component)).toMatchObject({ alignItems: 'center', backgroundColor: tokens.color.backgroundNeutralTransparent, @@ -275,8 +275,8 @@ describe('Tab', () => { }); expect(StyleSheet.flatten(labels[1].props.style)).toMatchObject({ fontWeight: tokens.fontWeight.functionalSemibold, - position: 'absolute', }); + expect(StyleSheet.flatten(labels[1].parent?.props.style)).toMatchObject({ position: 'absolute', justifyContent: 'center' }); }); it('exposes selected and disabled accessibility state', async () => { diff --git a/packages/agentic/components/src/primitives/compound-item-layout/CONTRACT.md b/packages/agentic/components/src/primitives/compound-item-layout/CONTRACT.md index 7a690695c3..5f5e0149a9 100644 --- a/packages/agentic/components/src/primitives/compound-item-layout/CONTRACT.md +++ b/packages/agentic/components/src/primitives/compound-item-layout/CONTRACT.md @@ -6,5 +6,10 @@ - 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. +- Regions are centered by default, including arbitrary icons and custom content. + Text-only rows with different font metrics can opt into `alignItems: 'baseline'` + on both `style` and the inline `contentStyle`. Baseline participation includes + leading and trailing content; do not apply it indiscriminately to image or + action-button regions. - Native root, accessibility, and test props not owned by the primitive are forwarded. diff --git a/packages/agentic/components/src/primitives/compound-item-layout/compound-item-layout.stories.tsx b/packages/agentic/components/src/primitives/compound-item-layout/compound-item-layout.stories.tsx index bc5f7bfe6b..9497fdb885 100644 --- a/packages/agentic/components/src/primitives/compound-item-layout/compound-item-layout.stories.tsx +++ b/packages/agentic/components/src/primitives/compound-item-layout/compound-item-layout.stories.tsx @@ -54,6 +54,9 @@ const styles = StyleSheet.create({ padding: 8, width: 360, }, + textBaseline: { + alignItems: 'baseline', + }, leading: { alignItems: 'center', backgroundColor: '#dce9ff', @@ -115,13 +118,13 @@ type Story = StoryObj; export const Default: Story = { render: ({ secondaryPosition }) => ( } leadingStyle={styles.leadingRegion} primary={Primary content} secondary={Secondary} secondaryPosition={secondaryPosition} - style={styles.layout} + style={[styles.layout, secondaryPosition !== 'under' && styles.textBaseline]} trailing={} trailingStyle={styles.trailingRegion} /> @@ -136,11 +139,12 @@ export const Overview: Story = { } leadingStyle={styles.leadingRegion} primary={Primary content} secondary={Secondary} - style={styles.layout} + style={[styles.layout, styles.textBaseline]} trailing={} trailingStyle={styles.trailingRegion} /> @@ -175,13 +179,13 @@ export const SecondaryPosition: Story = { {(['right', 'under'] as const).map((secondaryPosition) => ( } leadingStyle={styles.leadingRegion} primary={Primary content} secondary={Secondary} secondaryPosition={secondaryPosition} - style={styles.layout} + style={[styles.layout, secondaryPosition === 'right' && styles.textBaseline]} /> ))} diff --git a/packages/agentic/components/src/primitives/compound-item-layout/compound-item-layout.test.tsx b/packages/agentic/components/src/primitives/compound-item-layout/compound-item-layout.test.tsx index 83c5cec844..0a2e5c0d0c 100644 --- a/packages/agentic/components/src/primitives/compound-item-layout/compound-item-layout.test.tsx +++ b/packages/agentic/components/src/primitives/compound-item-layout/compound-item-layout.test.tsx @@ -1,5 +1,5 @@ /** @jsxImportSource @fluentui-react-native/framework-base */ -import { Text } from 'react-native'; +import { StyleSheet, Text } from 'react-native'; import { render } from '../../common/renderWithTheme'; @@ -22,4 +22,24 @@ describe('CompoundItemLayout', () => { expect(component.getByText('Secondary')).toBeOnTheScreen(); expect(component.getByText('Trailing')).toBeOnTheScreen(); }); + + it('allows text rows to opt into a shared baseline without changing the default centering policy', async () => { + const component = await render( + Primary} + secondary={Secondary} + style={{ alignItems: 'baseline' }} + testID="text-row" + trailing={Shortcut} + />, + ); + + expect(StyleSheet.flatten(component.getByTestId('text-row').props.style)).toMatchObject({ alignItems: 'baseline' }); + const content = component.getByText('Primary').parent?.parent; + expect(StyleSheet.flatten(content?.props.style)).toMatchObject({ alignItems: 'baseline' }); + + await component.rerender(Primary} testID="text-row" />); + expect(StyleSheet.flatten(component.getByTestId('text-row').props.style)).toMatchObject({ alignItems: 'center' }); + }); }); diff --git a/packages/agentic/components/src/primitives/focus-visual/CONTRACT.md b/packages/agentic/components/src/primitives/focus-visual/CONTRACT.md index 7a4565fe92..c604e8a05a 100644 --- a/packages/agentic/components/src/primitives/focus-visual/CONTRACT.md +++ b/packages/agentic/components/src/primitives/focus-visual/CONTRACT.md @@ -1,6 +1,8 @@ # FocusVisual contract -`FocusVisual` is an unstyled, decorative focus-ring structure. +`FocusVisual` is an unstyled, decorative ring structure. It can render focus +feedback or another outward indicator, such as Avatar's activity ring; the +consumer owns the meaning and visibility state. - The outer ring is always mounted; an optional inner ring enables a dual-ring visual. diff --git a/packages/agentic/components/src/primitives/icon/CONTRACT.md b/packages/agentic/components/src/primitives/icon/CONTRACT.md index 9ed83787a0..f3af092009 100644 --- a/packages/agentic/components/src/primitives/icon/CONTRACT.md +++ b/packages/agentic/components/src/primitives/icon/CONTRACT.md @@ -6,8 +6,21 @@ component boundary. - Image, font, and SVG sources are mutually exclusive. - Color, dimensions, accessibility, and test props are forwarded to the active renderer. +- The font renderer owns a sized, non-shrinking View that centers the intrinsic + Text line box. Do not impose `height` or `lineHeight` on the glyph to position + it: a font's ascent and descent are not its em size. +- With both frame dimensions specified, the glyph is an absolute child with no + edge offsets. This allows intrinsic height measurement even when the font's + line box exceeds the icon frame. A normal-flow Text can still be measured + against the frame's maximum height and render low. +- Font size is the smaller supplied dimension, or the sole supplied dimension. + Dimensioned glyphs disable font scaling because the explicit icon dimensions, + like image and SVG dimensions, own scaling. An undimensioned glyph retains + native default font sizing and scaling. Android font padding is disabled. +- The font frame owns the accessibility and test props; its Text is decorative. + Color and font family apply to the Text. Image and SVG renderers remain direct. - 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 +- The primitive does not expose a component ref because its image, font-frame, 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.test.tsx b/packages/agentic/components/src/primitives/icon/icon.test.tsx index b122b6e0cb..a89d4dc592 100644 --- a/packages/agentic/components/src/primitives/icon/icon.test.tsx +++ b/packages/agentic/components/src/primitives/icon/icon.test.tsx @@ -1,5 +1,5 @@ /** @jsxImportSource @fluentui-react-native/framework-base */ -import { View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import { render } from '../../common/renderWithTheme'; @@ -35,21 +35,71 @@ describe('Icon', () => { it('renders a font codepoint with dimensions and color', async () => { const component = await render( - , + , ); const text = component.getByText('🚀'); + expect(StyleSheet.flatten(component.getByTestId('font-icon').props.style)).toEqual({ + alignItems: 'center', + flexShrink: 0, + height: 24, + justifyContent: 'center', + width: 20, + }); expect(text.props.style).toEqual({ color: 'blue', + flexShrink: 0, fontFamily: 'IconFont', fontSize: 20, - height: 24, - lineHeight: 24, + includeFontPadding: false, padding: 0, + position: 'absolute', textAlign: 'center', - textAlignVertical: 'center', - width: 20, }); + expect(text.props).toMatchObject({ accessible: false, allowFontScaling: false, numberOfLines: 1 }); + }); + + it.each([ + { height: 12, width: 12, fontSize: 12 }, + { height: 16, width: 24, fontSize: 16 }, + { height: 32, width: 20, fontSize: 20 }, + { height: 16, width: undefined, fontSize: 16 }, + { height: undefined, width: 24, fontSize: 24 }, + { height: undefined, width: undefined, fontSize: undefined }, + ])('keeps intrinsic font metrics for $height by $width icons', async ({ height, width, fontSize }) => { + const component = await render(); + const glyph = component.getByText('✓'); + const glyphStyle = StyleSheet.flatten(glyph.props.style); + + expect(StyleSheet.flatten(component.getByTestId('font-icon').props.style)).toMatchObject({ height, width }); + expect(glyphStyle.fontSize).toBe(fontSize); + expect(glyph.props.allowFontScaling).toBe(fontSize === undefined); + expect(glyphStyle.position).toBe(height !== undefined && width !== undefined ? 'absolute' : undefined); + for (const property of ['height', 'width', 'lineHeight', 'textAlignVertical', 'transform', 'top', 'bottom', 'left', 'right']) { + expect(glyphStyle).not.toHaveProperty(property); + } + }); + + it('keeps font icon semantics on one frame and preserves caller accessibility', async () => { + const component = await render( + , + ); + expect(component.getAllByRole('image')).toHaveLength(1); + expect(component.getByRole('image').props).toMatchObject({ + accessibilityHint: 'Confirms the operation', + accessibilityLabel: 'Confirm', + accessible: true, + }); + + await component.rerender(); + expect(component.getByTestId('decorative').props).toMatchObject({ accessibilityRole: 'none', accessible: false }); + expect(component.queryByRole('image')).toBeNull(); }); it('forwards shared props to an SVG component', async () => { diff --git a/packages/agentic/components/src/primitives/icon/icon.tsx b/packages/agentic/components/src/primitives/icon/icon.tsx index cd8226ea2f..c38181fb31 100644 --- a/packages/agentic/components/src/primitives/icon/icon.tsx +++ b/packages/agentic/components/src/primitives/icon/icon.tsx @@ -1,5 +1,5 @@ /** @jsxImportSource @fluentui-react-native/framework-base */ -import { Image, Text } from 'react-native'; +import { Image, StyleSheet, Text, View } from 'react-native'; import type { ImageStyle, TextStyle } from 'react-native'; import { directComponent } from '@fluentui-react-native/framework-base'; @@ -29,21 +29,24 @@ export const Icon = directComponent(({ color, fontSource, height, ima if (fontSource) { const fontSize = getFontSize(height, width); const style: TextStyle = { + ...styles.glyph, color, fontFamily: fontSource.fontFamily, fontSize, - height, - lineHeight: height ?? fontSize, - padding: 0, - textAlign: 'center', - textAlignVertical: 'center', - width, + ...(height !== undefined && width !== undefined ? styles.framedGlyph : undefined), }; return ( - - {String.fromCodePoint(fontSource.codepoint)} - + + + {String.fromCodePoint(fontSource.codepoint)} + + ); } @@ -56,3 +59,21 @@ export const Icon = directComponent(({ color, fontSource, height, ima }); Icon.displayName = 'Icon'; + +const styles = StyleSheet.create({ + fontFrame: { + alignItems: 'center', + flexShrink: 0, + justifyContent: 'center', + }, + glyph: { + flexShrink: 0, + includeFontPadding: false, + padding: 0, + textAlign: 'center', + }, + framedGlyph: { + // Measure the full line box even when it is taller than the icon frame. + position: 'absolute', + }, +}); diff --git a/packages/agentic/components/src/primitives/layout-stable-text/CONTRACT.md b/packages/agentic/components/src/primitives/layout-stable-text/CONTRACT.md index 9ce02af76d..e943816488 100644 --- a/packages/agentic/components/src/primitives/layout-stable-text/CONTRACT.md +++ b/packages/agentic/components/src/primitives/layout-stable-text/CONTRACT.md @@ -4,7 +4,13 @@ - `reserve` is required, hidden from accessibility, and retains its text metrics in layout. -- `visible` is required and overlays the reserved text. +- `visible` is required and is vertically centered in an absolute-fill View over + the reserved text. The visible Text retains its own measured line box rather + than stretching to the reserve's height. +- Both labels use the same available width so wrapping and truncation remain + consistent. The caller supplies a reserve large enough for every visible state. - Consumer text styles are preserved before the primitive's structural styles. - The root remains inaccessible so the visible text supplies semantics. +- The overlay does not intercept input intended for the visible Text or its + owning control. - Native root and test props not owned by the primitive are forwarded. diff --git a/packages/agentic/components/src/primitives/layout-stable-text/layout-stable-text.stories.tsx b/packages/agentic/components/src/primitives/layout-stable-text/layout-stable-text.stories.tsx index 93ecf31ae1..19ead348b2 100644 --- a/packages/agentic/components/src/primitives/layout-stable-text/layout-stable-text.stories.tsx +++ b/packages/agentic/components/src/primitives/layout-stable-text/layout-stable-text.stories.tsx @@ -1,10 +1,14 @@ /** @jsxImportSource @fluentui-react-native/framework-base */ +import { useState } from 'react'; import type { ReactNode } from 'react'; import { StyleSheet, Text, View } from 'react-native'; +import type { LayoutRectangle } from 'react-native'; import type { Meta, StoryObj } from '@storybook/react-native'; +import type { WdioStory } from '@fluentui-react-native/storybook-desktop/testing'; import { LayoutStableText } from './layout-stable-text'; +import { StoryStatus } from '../../common/StoryStatus.story-helpers'; type StoryItemProps = { children: ReactNode; @@ -84,7 +88,7 @@ const meta: Meta = { export default meta; -type Story = StoryObj; +type Story = WdioStory>; export const Default: Story = { render: () => ( @@ -96,6 +100,39 @@ export const Default: Story = { ), }; +function AlignmentMeasurement() { + const [reserve, setReserve] = useState(); + const [visible, setVisible] = useState(); + return ( + + setReserve(event.nativeEvent.layout)} + > + Stable label} + visible={ + setVisible(event.nativeEvent.layout)} + > + Stable label + + } + /> + + + {reserve && visible ? JSON.stringify({ reserve, visible }) : 'Waiting for native layout'} + + + ); +} + export const Overview: Story = { render: () => ( @@ -106,10 +143,7 @@ export const Overview: Story = { /> - Stable label} - visible={Stable label} - /> + { + const assert: typeof import('node:assert') = (await import('node:assert')).default; + if (platform === 'macos') { + // Fabric paragraphs do not expose their testID to AX; use their native onLayout measurements. + const status = await browser.$('~layout-stable-text-native-metrics'); + await browser.waitUntil(async () => (await status.getText()).startsWith('{')); + const metrics: { reserve: LayoutRectangle; visible: LayoutRectangle } = JSON.parse(await status.getText()); + assert( + metrics.visible.height > 0 && metrics.visible.height < metrics.reserve.height, + 'Visible text must retain its intrinsic height.', + ); + assert( + Math.abs(metrics.visible.y + metrics.visible.height / 2 - metrics.reserve.height / 2) <= 1, + 'Visible text must be vertically centered within one layout pixel.', + ); + assert( + Math.abs(metrics.visible.x) <= 1 && metrics.visible.width <= metrics.reserve.width + 1, + 'Visible text must retain leading alignment and fit the reserved width.', + ); + return; + } + const reserve = await browser.$('~layout-stable-text-large-reserve'); + const visible = await browser.$('~layout-stable-text-small-visible'); + await expect(reserve).toExist(); + await expect(visible).toExist(); + const reservedBounds = await browser.getElementRect(await reserve.elementId); + const visibleBounds = await browser.getElementRect(await visible.elementId); + + assert( + visibleBounds.height > 0 && visibleBounds.height < reservedBounds.height, + 'Visible text must keep its smaller intrinsic height.', + ); + const reservedCenter = reservedBounds.y + reservedBounds.height / 2; + const visibleCenter = visibleBounds.y + visibleBounds.height / 2; + assert(Math.abs(reservedCenter - visibleCenter) <= 1, 'Visible text must be vertically centered within one layout pixel.'); + }, + }, }; export const WeightReservation: Story = { diff --git a/packages/agentic/components/src/primitives/layout-stable-text/layout-stable-text.test.tsx b/packages/agentic/components/src/primitives/layout-stable-text/layout-stable-text.test.tsx index 750edc0da1..a852424366 100644 --- a/packages/agentic/components/src/primitives/layout-stable-text/layout-stable-text.test.tsx +++ b/packages/agentic/components/src/primitives/layout-stable-text/layout-stable-text.test.tsx @@ -1,6 +1,7 @@ /** @jsxImportSource @fluentui-react-native/framework-base */ import { StyleSheet, Text } from 'react-native'; +import { fireEvent } from '@testing-library/react-native'; import { render } from '../../common/renderWithTheme'; import { LayoutStableText } from './layout-stable-text'; @@ -19,12 +20,59 @@ describe('LayoutStableText', () => { accessible: false, importantForAccessibility: 'no-hide-descendants', }); - expect(StyleSheet.flatten(visible.props.style)).toMatchObject({ + expect(StyleSheet.flatten(visible.parent?.props.style)).toMatchObject({ bottom: 0, + justifyContent: 'center', left: 0, position: 'absolute', right: 0, top: 0, }); + expect(StyleSheet.flatten(visible.props.style)).not.toHaveProperty('position'); + }); + + it('centers smaller visible text without replacing its typography or wrapping constraints', async () => { + const component = await render( + + A wrapping label + + } + visible={ + + A wrapping label + + } + style={{ width: 80 }} + testID="stable-text" + />, + ); + const visible = component.getByTestId('visible'); + + expect(visible.props.numberOfLines).toBe(2); + expect(StyleSheet.flatten(visible.props.style)).toEqual({ flexShrink: 1, fontSize: 12, lineHeight: 16 }); + expect(StyleSheet.flatten(visible.parent?.props.style)).toMatchObject({ left: 0, right: 0, justifyContent: 'center' }); + expect(StyleSheet.flatten(component.getByTestId('stable-text').props.style)).toMatchObject({ width: 80 }); + }); + + it('preserves visible text semantics and interaction through the overlay', async () => { + const onPress = jest.fn(); + const component = await render( + Label} + visible={ + + Label + + } + />, + ); + const visible = component.getByTestId('visible'); + + expect(visible.parent?.props).toMatchObject({ accessible: false, pointerEvents: 'box-none' }); + expect(visible.props.accessibilityLabel).toBe('Visible label'); + await fireEvent.press(visible); + expect(onPress).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/agentic/components/src/primitives/layout-stable-text/layout-stable-text.tsx b/packages/agentic/components/src/primitives/layout-stable-text/layout-stable-text.tsx index e3a8bec0f3..879a370575 100644 --- a/packages/agentic/components/src/primitives/layout-stable-text/layout-stable-text.tsx +++ b/packages/agentic/components/src/primitives/layout-stable-text/layout-stable-text.tsx @@ -15,13 +15,15 @@ export const LayoutStableText = directComponent(({ reserv testID: undefined, }; const visibleProps: TextProps = { - style: [visible.props.style, styles.visible], + style: [visible.props.style, styles.text], }; return ( {React.cloneElement(reserve, reserveProps)} - {React.cloneElement(visible, visibleProps)} + + {React.cloneElement(visible, visibleProps)} + ); }); @@ -39,5 +41,9 @@ const styles = StyleSheet.create({ }, visible: { ...StyleSheet.absoluteFillObject, + justifyContent: 'center', + }, + text: { + flexShrink: 1, }, }); diff --git a/packages/agentic/components/tsconfig.stories.json b/packages/agentic/components/tsconfig.stories.json index 20a1975ba7..1580140a47 100644 --- a/packages/agentic/components/tsconfig.stories.json +++ b/packages/agentic/components/tsconfig.stories.json @@ -8,12 +8,16 @@ "tsBuildInfoFile": ".cache/tsconfig.stories.tsbuildinfo" }, "include": [ + "src/components/avatar/avatar.stories.tsx", "src/components/button/button.stories.tsx", "src/components/checkbox/checkbox.stories.tsx", "src/components/input/input.stories.tsx", + "src/components/skeleton/skeleton.stories.tsx", + "src/components/spinner/spinner.stories.tsx", "src/components/switch/switch.stories.tsx", "src/components/tablist/tablist.stories.tsx", "src/primitives/focus-zone/focus-zone.stories.tsx", + "src/primitives/layout-stable-text/layout-stable-text.stories.tsx", "src/**/*.story-helpers.tsx", "src/**/*.wdio.ts" ], diff --git a/packages/agentic/storybook-desktop-runtime/README.md b/packages/agentic/storybook-desktop-runtime/README.md index d97fadd6ed..7ed37adeaa 100644 --- a/packages/agentic/storybook-desktop-runtime/README.md +++ b/packages/agentic/storybook-desktop-runtime/README.md @@ -26,6 +26,14 @@ utilities cannot consume opaque colors such as high-contrast `PlatformColor` values, so the chrome adapter warns and substitutes contrasting fixed colors from the selected theme for those values. +Both `background.content` and `background.preview` use `surfaceNeutralFar`. +Upstream LiteUI and its inner StoryView paint the canvas with `content`, even +when the surrounding desktop layout uses `preview`. Keeping those roles aligned +prevents the stock Default Flex, light, and dark canvases from matching the +Secondary Button's `backgroundNeutralSubtle` fill. Explicit story background +overrides still take precedence; component tokens and interaction colors are +unchanged. + It does not own W3C routing, WebdriverIO, test execution, evidence persistence, or native accessibility/input/screenshot providers. Those responsibilities belong to `@fluentui-react-native/desktop-driver`. diff --git a/packages/agentic/storybook-desktop-runtime/src/StorybookTheme.test.tsx b/packages/agentic/storybook-desktop-runtime/src/StorybookTheme.test.tsx index b88a5f766b..c4e5532fca 100644 --- a/packages/agentic/storybook-desktop-runtime/src/StorybookTheme.test.tsx +++ b/packages/agentic/storybook-desktop-runtime/src/StorybookTheme.test.tsx @@ -15,7 +15,7 @@ import { StorybookUIComponent } from './StorybookUI'; jest.mock('./DesktopDriverBridge', () => ({ DesktopDriverBridge: jest.fn(() => null) })); jest.mock('./StorybookUI', () => ({ StorybookUIComponent: jest.fn(() => null) })); -function createTestApp() { +function createTestApp(storyBackgroundColor?: string) { const roots: RootSettings[] = []; const bridgeRoots: RootSettings[] = []; const themes: ThemeState[] = []; @@ -44,7 +44,13 @@ function createTestApp() { throw new Error('Expected the app factory to configure the themed UI.'); } return () => ( - null, setItem: () => undefined }} theme={lightTheme}> + null, setItem: () => undefined }} + theme={lightTheme} + > ); @@ -89,6 +95,31 @@ describe('Storybook app theme root', () => { expect(roots).toHaveLength(renderCount); }); + it.each(['none', 'light', 'dark'])('keeps the %s canvas distinct from the Secondary Button fill', async (choice) => { + const { App, themes } = createTestApp(); + const scene = await render(); + + await fireEvent.press(scene.getByTestId(`test-theme-${choice}`)); + + const state = themes[themes.length - 1]; + const calls = jest.mocked(StorybookUIComponent).mock.calls; + const theme = calls[calls.length - 1][0].theme; + expect(theme.background.content).toBe(state.tokens.color.surfaceNeutralFar); + expect(theme.background.content).toBe(theme.background.preview); + expect(theme.background.content).not.toBe(state.tokens.color.backgroundNeutralSubtle); + }); + + it.each(['#ffeedd', 'transparent'])('preserves the explicit %s story background across theme changes', async (storyBackgroundColor) => { + const { App } = createTestApp(storyBackgroundColor); + const scene = await render(); + + for (const choice of ['none', 'dark', 'light']) { + await fireEvent.press(scene.getByTestId(`test-theme-${choice}`)); + const calls = jest.mocked(StorybookUIComponent).mock.calls; + expect(calls[calls.length - 1][0].storyBackgroundColor).toBe(storyBackgroundColor); + } + }); + it('switches chrome and story colors together without recreating Storybook or resetting the scene', async () => { const warn = jest.spyOn(console, 'warn').mockImplementation(); const { App, roots, themes, mounted, unmounted, getStorybookUI } = createTestApp(); @@ -114,7 +145,7 @@ describe('Storybook app theme root', () => { const background = theme.base === 'dark' ? state.tokens.color.fixedBlack : state.tokens.color.fixedWhite; const literal = (value: unknown, fallback: unknown) => (typeof value === 'string' ? value : fallback); expect(theme.background.app).toBe(literal(state.tokens.color.surfaceNeutralFar, background)); - expect(theme.background.content).toBe(literal(state.tokens.color.surfaceNeutralNear, background)); + expect(theme.background.content).toBe(literal(state.tokens.color.surfaceNeutralFar, background)); expect(theme.background.preview).toBe(literal(state.tokens.color.surfaceNeutralFar, background)); expect(theme.color.defaultText).toBe(literal(state.tokens.color.foregroundNeutralPrimary, foreground)); expect(theme.input.color).toBe(literal(state.tokens.color.foregroundNeutralPrimary, foreground)); diff --git a/packages/agentic/storybook-desktop-runtime/src/useStorybookTheme.test.tsx b/packages/agentic/storybook-desktop-runtime/src/useStorybookTheme.test.tsx index cb1579328f..1118ab7d3a 100644 --- a/packages/agentic/storybook-desktop-runtime/src/useStorybookTheme.test.tsx +++ b/packages/agentic/storybook-desktop-runtime/src/useStorybookTheme.test.tsx @@ -32,6 +32,8 @@ describe('Storybook theme palette', () => { ); expect(themes[0].background.app).toBe('#123456'); + expect(themes[0].background.content).toBe('#123456'); + expect(themes[0].background.preview).toBe('#123456'); expect(themes[0].color.defaultText).toBe('#abcdef'); await scene.rerender( @@ -42,6 +44,8 @@ describe('Storybook theme palette', () => { await act(() => source.update({ color: { surfaceNeutralFar: '#654321' } })); expect(themes[themes.length - 1].background.app).toBe('#654321'); + expect(themes[themes.length - 1].background.content).toBe('#654321'); + expect(themes[themes.length - 1].background.preview).toBe('#654321'); expect(themes[themes.length - 1]).not.toBe(themes[0]); expect(themes[themes.length - 1].appBorderColor).toBe(states[states.length - 1].tokens.color.strokeNeutralSubtle); }); @@ -60,4 +64,29 @@ describe('Storybook theme palette', () => { expect(warn).toHaveBeenCalledTimes(1); expect(warn).toHaveBeenCalledWith(expect.stringContaining('foregroundBrandPrimary')); }); + + it.each([ + ['light', 'standard', 'fixedWhite'], + ['dark', 'standard', 'fixedBlack'], + ['light', 'highContrast', 'fixedBlack'], + ['dark', 'highContrast', 'fixedBlack'], + ] as const)('retains the native canvas fallback for %s/%s', async (colorScheme, contrast, fallback) => { + const warn = jest.spyOn(console, 'warn').mockImplementation(); + const themes: Theme[] = []; + const states: ThemeState[] = []; + const source = new FlexThemeReference({ + appearance: { colorScheme, contrast }, + base: { color: { surfaceNeutralFar: PlatformColor('windowBackgroundColor') } }, + }); + await render( + + + , + ); + + expect(themes[0].background.content).toBe(states[0].tokens.color[fallback]); + expect(themes[0].background.preview).toBe(states[0].tokens.color[fallback]); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('surfaceNeutralFar')); + }); }); diff --git a/packages/agentic/storybook-desktop-runtime/src/useStorybookTheme.ts b/packages/agentic/storybook-desktop-runtime/src/useStorybookTheme.ts index 06819d0bdb..840cc7d9e9 100644 --- a/packages/agentic/storybook-desktop-runtime/src/useStorybookTheme.ts +++ b/packages/agentic/storybook-desktop-runtime/src/useStorybookTheme.ts @@ -77,7 +77,8 @@ function storybookThemeFromThemeState({ appearance, highContrast, tokens }: Them ...base.background, app: color('surfaceNeutralFar', 'background'), bar: color('backgroundNeutralSubtle', 'background'), - content: color('surfaceNeutralNear', 'background'), + // LiteUI and StoryView paint the canvas with content rather than preview. + content: color('surfaceNeutralFar', 'background'), preview: color('surfaceNeutralFar', 'background'), hoverable: color('backgroundNeutralSoft', 'background'), positive: color('backgroundSuccessSubtle', 'background'), diff --git a/packages/framework-base/src/hooks/useSharedAnimatedLoop.native.ts b/packages/framework-base/src/hooks/useSharedAnimatedLoop.native.ts index 06c4669442..a85d7d0de0 100644 --- a/packages/framework-base/src/hooks/useSharedAnimatedLoop.native.ts +++ b/packages/framework-base/src/hooks/useSharedAnimatedLoop.native.ts @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Animated, Easing } from 'react-native'; +import { Animated, Easing, Platform } from 'react-native'; export type SharedAnimatedLoopOptions = { channel: string; @@ -57,7 +57,10 @@ function scheduleCleanup(key: string, loop: SharedAnimatedLoop): void { * channel with the same timing configuration. */ export function useSharedAnimatedLoop({ channel, duration, enabled, useNativeDriver }: SharedAnimatedLoopOptions): Animated.Value { - const key = `${channel}:${duration}:${useNativeDriver ? 'native' : 'javascript'}`; + // RNmacOS Fabric currently leaves native-driven transforms at their initial value. + const isMacOSFabric = Platform.OS === 'macos' && Reflect.get(globalThis, 'nativeFabricUIManager') != null; + const resolvedUseNativeDriver = useNativeDriver && !isMacOSFabric; + const key = `${channel}:${duration}:${resolvedUseNativeDriver ? 'native' : 'javascript'}`; const sharedLoop = React.useMemo(() => getSharedLoop(key), [key]); React.useEffect(() => { @@ -83,7 +86,8 @@ export function useSharedAnimatedLoop({ channel, duration, enabled, useNativeDri toValue: 1, duration, easing: Easing.linear, - useNativeDriver, + isInteraction: false, + useNativeDriver: resolvedUseNativeDriver, }), ); sharedLoop.animation.start(); @@ -99,7 +103,7 @@ export function useSharedAnimatedLoop({ channel, duration, enabled, useNativeDri scheduleCleanup(key, sharedLoop); } }; - }, [duration, enabled, key, sharedLoop, useNativeDriver]); + }, [duration, enabled, key, sharedLoop, resolvedUseNativeDriver]); return sharedLoop.value; } diff --git a/packages/framework-base/src/hooks/useSharedAnimatedLoop.test.tsx b/packages/framework-base/src/hooks/useSharedAnimatedLoop.test.tsx index 0f20eab153..f595aff602 100644 --- a/packages/framework-base/src/hooks/useSharedAnimatedLoop.test.tsx +++ b/packages/framework-base/src/hooks/useSharedAnimatedLoop.test.tsx @@ -1,12 +1,45 @@ import { act } from 'react'; -import { Animated, Easing } from 'react-native'; +import { Animated, Easing, Platform } from 'react-native'; import * as renderer from 'react-test-renderer'; import { useSharedAnimatedLoop } from './useSharedAnimatedLoop'; describe('useSharedAnimatedLoop', () => { + const fabricDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'nativeFabricUIManager'); + afterEach(() => { jest.restoreAllMocks(); + if (fabricDescriptor) { + Object.defineProperty(globalThis, 'nativeFabricUIManager', fabricDescriptor); + } else { + Reflect.deleteProperty(globalThis, 'nativeFabricUIManager'); + } + }); + + it.each([ + ['macos', true, true, false], + ['macos', false, true, true], + ['ios', true, true, true], + ['windows', true, true, true], + ['macos', true, false, false], + ] as const)('resolves the driver on %s with Fabric=%s and native=%s', (platform, fabric, requested, expected) => { + jest.replaceProperty(Platform, 'OS', platform); + Object.defineProperty(globalThis, 'nativeFabricUIManager', { configurable: true, value: fabric ? {} : undefined }); + const timing = jest.spyOn(Animated, 'timing').mockReturnValue({ start: jest.fn(), stop: jest.fn() } as never); + jest.spyOn(Animated, 'loop').mockReturnValue({ start: jest.fn(), stop: jest.fn() } as never); + const Harness = () => { + useSharedAnimatedLoop({ channel: 'renderer-driver', duration: 1500, enabled: true, useNativeDriver: requested }); + return null; + }; + let tree!: renderer.ReactTestRenderer; + act(() => { + tree = renderer.create(); + }); + expect(timing).toHaveBeenCalledWith( + expect.any(Animated.Value), + expect.objectContaining({ isInteraction: false, useNativeDriver: expected }), + ); + act(() => tree.unmount()); }); it('shares one running value for subscribers on the same channel', () => {