From a3f2795154739190b577d3d1f5b23d57fb6a9549 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Wed, 2 Sep 2026 16:21:32 -0700 Subject: [PATCH 01/22] Add the agentic AvatarGroup component AvatarGroup lays a small set of Avatar children out as one cohort and optionally appends a trailing `+N` indicator for the members it does not show. It supports the spread and stack layouts and the eight Avatar sizes, and it resolves the spread gap, stack overlap, separation ring, item box, and indicator scale from its own declared size. React Native has no sibling-relative sizing and no mask compositing, so the contract records five accepted divergences: size is declared on the group for geometry only, the stack separation ring is a filled circular box rather than a mask, the indicator is its own view and text rather than an Avatar, the five-item maximum is advisory, and a labelled group uses the image role because React Native has no group role. Adds the reviewed contract and companions, the implementation, public exports, runtime and type tests, Storybook stories with a static desktop driver plan, and a changeset. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/tidy-pears-gather.md | 5 + .../components/spec-source-report.json | 27 +- .../src/components/avatar-group/SPEC.md | 62 ++++ .../avatar-group/avatar-group.stories.tsx | 218 +++++++++++++ .../avatar-group/avatar-group.styles.ts | 258 +++++++++++++++ .../avatar-group/avatar-group.test.tsx | 296 ++++++++++++++++++ .../components/avatar-group/avatar-group.ts | 14 + .../avatar-group/avatar-group.types.test.ts | 55 ++++ .../avatar-group/avatar-group.types.ts | 85 +++++ .../avatar-group/renderAvatarGroup.tsx | 29 ++ .../avatar-group/spec/accessibility.md | 9 + .../avatar-group/spec/interaction.md | 7 + .../components/avatar-group/spec/source.json | 120 +++++++ .../components/avatar-group/spec/tokens.yaml | 94 ++++++ .../src/components/avatar-group/spec/usage.md | 11 + .../components/avatar-group/useAvatarGroup.ts | 107 +++++++ .../avatar-group/useAvatarGroupStyles.ts | 32 ++ packages/agentic/components/src/index.test.ts | 4 + packages/agentic/components/src/index.ts | 12 + 19 files changed, 1440 insertions(+), 5 deletions(-) create mode 100644 .changeset/tidy-pears-gather.md create mode 100644 packages/agentic/components/src/components/avatar-group/SPEC.md create mode 100644 packages/agentic/components/src/components/avatar-group/avatar-group.stories.tsx create mode 100644 packages/agentic/components/src/components/avatar-group/avatar-group.styles.ts create mode 100644 packages/agentic/components/src/components/avatar-group/avatar-group.test.tsx create mode 100644 packages/agentic/components/src/components/avatar-group/avatar-group.ts create mode 100644 packages/agentic/components/src/components/avatar-group/avatar-group.types.test.ts create mode 100644 packages/agentic/components/src/components/avatar-group/avatar-group.types.ts create mode 100644 packages/agentic/components/src/components/avatar-group/renderAvatarGroup.tsx create mode 100644 packages/agentic/components/src/components/avatar-group/spec/accessibility.md create mode 100644 packages/agentic/components/src/components/avatar-group/spec/interaction.md create mode 100644 packages/agentic/components/src/components/avatar-group/spec/source.json create mode 100644 packages/agentic/components/src/components/avatar-group/spec/tokens.yaml create mode 100644 packages/agentic/components/src/components/avatar-group/spec/usage.md create mode 100644 packages/agentic/components/src/components/avatar-group/useAvatarGroup.ts create mode 100644 packages/agentic/components/src/components/avatar-group/useAvatarGroupStyles.ts diff --git a/.changeset/tidy-pears-gather.md b/.changeset/tidy-pears-gather.md new file mode 100644 index 0000000000..754ec6e8aa --- /dev/null +++ b/.changeset/tidy-pears-gather.md @@ -0,0 +1,5 @@ +--- +"@fluentui-react-native/components": minor +--- + +Add the agentic AvatarGroup component with spread and stack layouts, size-scaled geometry, and an optional `+N` overflow indicator. diff --git a/packages/agentic/components/spec-source-report.json b/packages/agentic/components/spec-source-report.json index 05fe03b7d0..37e1bfe65c 100644 --- a/packages/agentic/components/spec-source-report.json +++ b/packages/agentic/components/spec-source-report.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "generatedAt": "2026-08-31T05:04:03.307Z", + "generatedAt": "2026-09-02T23:19:05.422Z", "sourceLock": "flex-1.5.0-206c4996", "sourceLockFingerprint": "a69997212ec1b89510c94176801bf5a146ed7e7d8c80cc7db40ac8f60cf9f119", "baseline": { @@ -10,8 +10,8 @@ "external": { "mode": "live", "status": "drift-detected", - "marketplaceHead": "eb8cf20cb1c6ce4fdc563ac1ebd34197cb347735", - "originHead": "dfdaeba2f79c2c2c33cdc074cf1e60f1c3a2929c" + "marketplaceHead": "2c7eaade7e98b5a12a7b12d1d432441844ed1145", + "originHead": "cbe3cca9207ac0c24c3f965ec483788ee5e11c44" }, "catalog": { "releaseEntries": [ @@ -126,6 +126,7 @@ "contracts": [ "accordion", "avatar", + "avatar-group", "badge", "button", "card", @@ -149,6 +150,7 @@ "implemented": [ "accordion", "avatar", + "avatar-group", "badge", "button", "card", @@ -169,7 +171,6 @@ "text" ], "implementationGap": [ - "avatar-group", "breadcrumb", "combobox", "destructive-button", @@ -201,7 +202,6 @@ "tooltip" ], "noLocalContract": [ - "avatar-group", "breadcrumb", "combobox", "destructive-button", @@ -468,6 +468,23 @@ }, "candidateStatus": "current" }, + { + "component": "avatar-group", + "lifecycle": "implemented", + "conformance": "reviewed", + "releaseDifferences": [], + "marketplaceDrift": { + "added": [], + "removed": [], + "modified": [] + }, + "originDrift": { + "added": [], + "removed": [], + "modified": [] + }, + "candidateStatus": "current" + }, { "component": "badge", "lifecycle": "implemented", diff --git a/packages/agentic/components/src/components/avatar-group/SPEC.md b/packages/agentic/components/src/components/avatar-group/SPEC.md new file mode 100644 index 0000000000..3e4d4e2bf1 --- /dev/null +++ b/packages/agentic/components/src/components/avatar-group/SPEC.md @@ -0,0 +1,62 @@ +--- +name: avatar-group +platform: react-native (Windows, macOS) +status: implemented +source: ./spec/source.json +tokens: ./spec/tokens.yaml +accessibility: ./spec/accessibility.md +interaction: ./spec/interaction.md +usage: ./spec/usage.md +--- + +# AvatarGroup + +## Scope + +AvatarGroup is a non-interactive layout row that presents several Avatar children as one cohort and optionally appends a trailing `+N` indicator for members that are not shown. It positions the children it is given and resolves the group's own geometry from a declared size. It does not fetch members, sort them, decide how many are visible, own presence or activity status, add press or focus behavior, or restyle the Avatar children it renders. + +## Public contract + +`layout` defaults to `spread` and accepts `spread` or `stack`. `size` defaults to `40` and accepts `16`, `20`, `24`, `28`, `32`, `40`, `56`, or `120`. `overflowCount` defaults to `0`. `children` holds the visible Avatar elements. `root` is required, and `overflow` is an optional slot for the trailing indicator container. + +`spread` separates the items with a size-scaled gap so every circle is fully visible. `stack` overlaps the items by a size-scaled negative leading offset and centers each item inside a circular box filled with the group's surface colour, so the surrounding ring paints the separation gap that keeps each face distinct. Later items paint over earlier items in both layouts, so the trailing item is in front. + +`size` governs only the group's own geometry: the spread gap, the stack overlap, the stack separation-ring width, the item box, and the indicator's diameter, border, and text scale. AvatarGroup never rewrites a child's props, so each Avatar keeps whatever `size` the caller gave it; a development warning reports a child whose explicit `size` disagrees with the group. + +The indicator renders after the children whenever `overflowCount` is `1` or more, except at size `16`, where the glyph cannot be read and the indicator is suppressed with a development warning. Its text is `+N` and saturates at `+99`; exact totals above that belong in the group's accessible name. Five rendered items is the design maximum. Exceeding it is accepted rather than truncated, and reported with a development warning. + +The resolved state retains layout, size, overflow count, indicator text, the item and item-offset styles, theme state, and the user root style. User style is applied after component styles. AvatarGroup owns no interaction state. + +### Requirements + +- **AVG-001:** Resolve the layout axis and apply the per-size spread gap, the stack overlap, and the stack separation ring, keeping trailing items in front. +- **AVG-002:** Resolve the declared size for group geometry only, leave child props untouched, and warn in development when a child's explicit size disagrees. +- **AVG-003:** Render the trailing indicator only when the hidden count is positive, format its text as `+N` saturated at `+99`, and suppress it at size `16` with a development warning. +- **AVG-004:** Expose a labelled group as one accessible image node, leave an unlabelled group as a transparent layout row whose children announce themselves, and keep the indicator decorative until it is given its own label. +- **AVG-005:** Add no press, hover, focus, disabled, selected, or motion behaviour, forward the broad root `ViewProps` surface, and retain the user root style after component styles. +- **AVG-006:** Treat five rendered items as the advisory design maximum and warn in development rather than dropping caller content. + +## Platform behavior + +A group with `accessibilityLabel` is accessible with the React Native image role, so Windows exposes it as a UI Automation image and macOS as an AX image, and the cohort announces once instead of one node per member. Without a label the root carries the `none` role and stays a plain layout row, so each Avatar child announces its own accessible name in source order. Callers can still set `accessible` and `accessibilityRole` explicitly. + +React Native paints later siblings above earlier ones on both target platforms, so stack order needs no explicit `zIndex`. The stack separation ring is an ordinary filled circular box rather than a border, an outline, or a mask, so toggling layout never creates a border visual after mount. AvatarGroup adds no tab stop and renders no `FocusVisual`. + +## Divergences from Flex + +- `avatar-group-size-declared-on-group` — **accepted.** The source delegates size entirely to the Avatar children. React Native has no sibling-relative sizing, so the group must know the size to resolve its gap, overlap, ring width, and indicator. FURN declares `size` on the group for geometry only, leaves each child's own size untouched, and warns in development when the two disagree. +- `avatar-group-stack-separation-ring` — **accepted.** The source specifies a masked circular cut-out with a painted outside-stroke fallback. React Native has no mask compositing, so FURN adopts the fallback: each stacked item is centred in a `color.surfaceNeutralNearer` circular box whose annulus paints the separation gap. A stacked group should therefore sit on that surface. +- `avatar-group-overflow-not-an-avatar` — **accepted.** The source builds the indicator from an Avatar in initials mode. FURN's Avatar normalises initials to at most two characters, so `+99` cannot survive that path. FURN renders the indicator from its own view and text slots and binds the equivalent Avatar tokens directly. +- `avatar-group-slot-maximum-advisory` — **accepted.** The source states a hard five-slot maximum. FURN treats it as advisory: a layout container that silently dropped caller content would be harder to diagnose than a development warning. +- `avatar-group-labeled-group-role` — **accepted.** React Native has no `group` accessibility role. A labelled FURN group therefore uses the image role, which matches the source's collapsed single-image pattern, and an unlabelled group stays a transparent row so individual identities are still announced. + +## Conformance + +| Requirement | Evidence | +| ----------- | ------------------------------------------------------------------------------------------------------- | +| AVG-001 | `avatar-group.styles.ts`, `useAvatarGroupStyles.ts`, `renderAvatarGroup.tsx`, `avatar-group.test.tsx` | +| AVG-002 | `avatar-group.types.ts`, `useAvatarGroup.ts`, `avatar-group.test.tsx`, `avatar-group.types.test.ts` | +| AVG-003 | `useAvatarGroup.ts`, `avatar-group.styles.ts`, `avatar-group.test.tsx` | +| AVG-004 | `useAvatarGroup.ts`, `useAvatarGroupStyles.ts`, `avatar-group.test.tsx` | +| AVG-005 | `avatar-group.types.ts`, `useAvatarGroupStyles.ts`, `avatar-group.stories.tsx`, `avatar-group.test.tsx` | +| AVG-006 | `useAvatarGroup.ts`, `avatar-group.test.tsx` | diff --git a/packages/agentic/components/src/components/avatar-group/avatar-group.stories.tsx b/packages/agentic/components/src/components/avatar-group/avatar-group.stories.tsx new file mode 100644 index 0000000000..3a1a61bf6d --- /dev/null +++ b/packages/agentic/components/src/components/avatar-group/avatar-group.stories.tsx @@ -0,0 +1,218 @@ +/** @jsxImportSource @fluentui-react-native/framework-base */ +import type { ReactNode } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +import type { Meta, StoryObj } from '@storybook/react-native'; +import type { DesktopStoryTests } from '@fluentui-react-native/desktop-driver/authoring'; + +import { Avatar } from '../avatar/avatar'; +import { AvatarGroup } from './avatar-group'; +import type { AvatarGroupLayout, AvatarGroupSize } from './avatar-group.types'; + +type StoryGroupProps = { + children: ReactNode; + label: string; +}; + +const StoryGroup = ({ children, label }: StoryGroupProps) => ( + + {label} + {children} + +); + +const members: readonly { initials: string; name: string }[] = [ + { initials: 'LM', name: 'Lydia Mitchelson' }, + { initials: 'RK', name: 'Rahul Kapoor' }, + { initials: 'AC', name: 'Amanda Cruz' }, +]; + +const layouts: readonly { label: string; value: AvatarGroupLayout }[] = [ + { label: 'Spread', value: 'spread' }, + { label: 'Stack', value: 'stack' }, +]; + +const sizes: readonly AvatarGroupSize[] = [16, 20, 24, 28, 32, 40, 56, 120]; + +const renderMembers = (size: AvatarGroupSize, count = members.length) => + members.slice(0, count).map(({ initials, name }) => ); + +const meta: Meta = { + title: 'Components/AvatarGroup', + component: AvatarGroup, + args: { + accessibilityLabel: 'Document collaborators', + layout: 'spread', + overflowCount: 0, + size: 40, + testID: 'agentic-storybook-avatar-group', + }, + argTypes: { + layout: { control: 'select', options: layouts.map(({ value }) => value) }, + overflowCount: { control: { type: 'number', min: 0, step: 1 } }, + size: { control: 'select', options: sizes }, + }, + parameters: { + docs: { + description: { + component: + 'AvatarGroup lays a small set of Avatar items out in a single row, either spread apart or stacked with a separation ring, and appends an optional `+N` indicator for the members it does not show. It is non-interactive, and it announces the cohort once when it carries an accessible name.', + }, + }, + }, + render: (args) => {renderMembers(args.size ?? 40)}, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + tags: ['desktop-e2e'], + parameters: { + desktopDriver: { + version: 1, + tests: [ + { + id: 'named-group', + title: 'Announces the labeled cohort as a single element', + steps: [ + { action: 'wait', target: { testId: 'agentic-storybook-avatar-group' } }, + { expect: { state: 'displayed', target: { testId: 'agentic-storybook-avatar-group' }, value: true } }, + { + expect: { + state: 'accessibleName', + target: { testId: 'agentic-storybook-avatar-group' }, + value: 'Document collaborators', + }, + }, + { action: 'screenshot', name: 'avatar-group-default', target: { testId: 'agentic-storybook-avatar-group' } }, + ], + }, + ], + } satisfies DesktopStoryTests, + }, +}; + +export const Overview: Story = { + render: () => ( + + + {renderMembers(40)} + + + + {renderMembers(40)} + + + + + {renderMembers(40)} + + + + ), + parameters: { + docs: { + description: { + story: 'A grouped scan of the two layouts and the overflow indicator.', + }, + }, + }, +}; + +export const Layouts: Story = { + render: () => ( + + {layouts.map(({ label, value }) => ( + + + {renderMembers(40)} + + + ))} + + ), + parameters: { + docs: { + description: { + story: 'Spread separates the items with a size-scaled gap; stack overlaps them and paints a separation ring between them.', + }, + }, + }, +}; + +export const Sizes: Story = { + render: () => ( + + {layouts.map(({ label, value }) => ( + + {sizes.map((size) => ( + + {renderMembers(size, 2)} + + ))} + + ))} + + ), + parameters: { + docs: { + description: { + story: 'The group resolves its gap, overlap, and ring from its own size, so each child Avatar needs the same size.', + }, + }, + }, +}; + +export const Overflow: Story = { + render: () => ( + + + {[1, 9, 42, 250].map((overflowCount) => ( + + {renderMembers(40, 2)} + + ))} + + + + {renderMembers(40, 2)} + + + + ), + parameters: { + docs: { + description: { + story: 'The indicator saturates at `+99`, and passing `overflow={null}` suppresses it even when the count is positive.', + }, + }, + }, +}; + +const styles = StyleSheet.create({ + group: { + alignItems: 'flex-start', + gap: 8, + }, + label: { + fontSize: 12, + fontWeight: '600', + }, + row: { + alignItems: 'center', + flexDirection: 'row', + flexWrap: 'wrap', + gap: 16, + }, + story: { + alignItems: 'flex-start', + gap: 16, + }, +}); diff --git a/packages/agentic/components/src/components/avatar-group/avatar-group.styles.ts b/packages/agentic/components/src/components/avatar-group/avatar-group.styles.ts new file mode 100644 index 0000000000..b6f6547dc0 --- /dev/null +++ b/packages/agentic/components/src/components/avatar-group/avatar-group.styles.ts @@ -0,0 +1,258 @@ +import { StyleSheet } from 'react-native'; +import type { TextStyle, ViewStyle } from 'react-native'; + +import type { FlexTokens } from '@fluentui-react-native/design'; + +import { getGapStyleValue, getNumericStyleValue, getThemedStateStyleFactory } from '@fluentui-react-native/design/styling'; +import type { StateNames, StyleDefinition } from '@fluentui-react-native/design/styling'; +import type { AvatarGroupSize, AvatarGroupState } from './avatar-group.types'; + +export const avatarGroupSizes = [16, 20, 24, 28, 32, 40, 56, 120] as const; + +export const avatarGroupStyles = StyleSheet.create({ + item: { + alignItems: 'center', + justifyContent: 'center', + }, + overflow: { + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + }, + overflowText: { + padding: 0, + textAlign: 'center', + textAlignVertical: 'center', + }, + root: { + alignItems: 'center', + alignSelf: 'flex-start', + flexDirection: 'row', + }, +}); + +const avatarGroupSizeStates = ['16', '20', '24', '28', '32', '40', '56', '120'] as const; +const avatarGroupLayoutStates = ['spread', 'stack'] as const; +const avatarGroupOffsetStates = ['offset'] as const; + +const avatarGroupRootStateLevels = [avatarGroupSizeStates, avatarGroupLayoutStates] as const; +type AvatarGroupRootStateLevels = typeof avatarGroupRootStateLevels; +type AvatarGroupRootState = StateNames; + +const avatarGroupItemStateLevels = [avatarGroupSizeStates, avatarGroupLayoutStates, avatarGroupOffsetStates] as const; +type AvatarGroupItemStateLevels = typeof avatarGroupItemStateLevels; +type AvatarGroupItemState = StateNames; + +const avatarGroupOverflowStateLevels = [avatarGroupSizeStates] as const; +type AvatarGroupOverflowStateLevels = typeof avatarGroupOverflowStateLevels; +type AvatarGroupOverflowState = StateNames; + +/** + * The trailing edge of each stacked item that its successor covers. The scale is a quarter of the avatar + * diameter, which the spacing tokens cannot express at every stop. + */ +const stackOverlap: Record = { + 16: 4, + 20: 5, + 24: 6, + 28: 7, + 32: 8, + 40: 10, + 56: 14, + 120: 30, +}; + +function toNumber(value: unknown): number { + return Number(getNumericStyleValue(value)); +} + +/** + * The separation ring painted between stacked items, and the boundary drawn around the overflow indicator. + * Size 120 needs four pixels, which the stroke-width scale does not reach. + */ +function getRingWidth({ strokeWidth }: FlexTokens, size: AvatarGroupSize): number { + if (size === 120) { + return 4; + } + if (size === 56) { + return toNumber(strokeWidth.thicker); + } + if (size === 40) { + return toNumber(strokeWidth.thick); + } + return toNumber(strokeWidth.thin); +} + +function getSpreadGap({ spacing }: FlexTokens, size: AvatarGroupSize): ViewStyle['gap'] { + if (size === 120) { + return getGapStyleValue(spacing.componentBase500); + } + if (size === 16) { + return getGapStyleValue(spacing.componentBase200); + } + if (size === 32 || size === 40 || size === 56) { + return getGapStyleValue(spacing.componentBase300); + } + return getGapStyleValue(spacing.componentBase250); +} + +function getOverflowFontSize({ fontSize }: FlexTokens, size: AvatarGroupSize): number { + switch (size) { + case 16: + case 20: + case 24: + return toNumber(fontSize.functionalCaption); + case 28: + return toNumber(fontSize.functionalBodySmall); + case 32: + return toNumber(fontSize.functionalBodyMedium); + case 40: + return toNumber(fontSize.functionalBodyLarge); + case 56: + return toNumber(fontSize.functionalTitleSmall); + default: + return toNumber(fontSize.functionalTitleLarge); + } +} + +function createRootSizeStyle( + tokens: FlexTokens, + size: AvatarGroupSize, +): StyleDefinition { + return { + spread: { gap: getSpreadGap(tokens, size) }, + stack: { gap: 0 }, + }; +} + +function createItemSizeStyle( + tokens: FlexTokens, + size: AvatarGroupSize, +): StyleDefinition { + const ringWidth = getRingWidth(tokens, size); + const stackBox = size + ringWidth * 2; + + return { + spread: { + backgroundColor: tokens.color.backgroundNeutralTransparent, + borderRadius: tokens.borderRadius.circular, + height: size, + width: size, + offset: { marginStart: 0 }, + }, + stack: { + backgroundColor: tokens.color.surfaceNeutralNearer, + borderRadius: tokens.borderRadius.circular, + height: stackBox, + width: stackBox, + offset: { marginStart: -(stackOverlap[size] + ringWidth * 2) }, + }, + }; +} + +function createOverflowSizeStyle(tokens: FlexTokens, size: AvatarGroupSize): ViewStyle { + return { + borderWidth: getRingWidth(tokens, size), + height: size, + width: size, + }; +} + +function createOverflowTextSizeStyle(tokens: FlexTokens, size: AvatarGroupSize): TextStyle { + const fontSize = getOverflowFontSize(tokens, size); + return { fontSize, lineHeight: fontSize }; +} + +const getThemedAvatarGroupRootStyle = getThemedStateStyleFactory( + 'AvatarGroup.root', + (tokens: FlexTokens): StyleDefinition => ({ + alignItems: 'center', + flexDirection: 'row', + '16': createRootSizeStyle(tokens, 16), + '20': createRootSizeStyle(tokens, 20), + '24': createRootSizeStyle(tokens, 24), + '28': createRootSizeStyle(tokens, 28), + '32': createRootSizeStyle(tokens, 32), + '40': createRootSizeStyle(tokens, 40), + '56': createRootSizeStyle(tokens, 56), + '120': createRootSizeStyle(tokens, 120), + }), + avatarGroupRootStateLevels, +); + +const getThemedAvatarGroupItemStyle = getThemedStateStyleFactory( + 'AvatarGroup.item', + (tokens: FlexTokens): StyleDefinition => ({ + alignItems: 'center', + justifyContent: 'center', + '16': createItemSizeStyle(tokens, 16), + '20': createItemSizeStyle(tokens, 20), + '24': createItemSizeStyle(tokens, 24), + '28': createItemSizeStyle(tokens, 28), + '32': createItemSizeStyle(tokens, 32), + '40': createItemSizeStyle(tokens, 40), + '56': createItemSizeStyle(tokens, 56), + '120': createItemSizeStyle(tokens, 120), + }), + avatarGroupItemStateLevels, +); + +const getThemedAvatarGroupOverflowStyle = getThemedStateStyleFactory( + 'AvatarGroup.overflow', + (tokens: FlexTokens): StyleDefinition => ({ + backgroundColor: tokens.color.surfaceNeutralNearer, + borderColor: tokens.color.strokeNeutralSubtle, + borderRadius: tokens.borderRadius.circular, + '16': createOverflowSizeStyle(tokens, 16), + '20': createOverflowSizeStyle(tokens, 20), + '24': createOverflowSizeStyle(tokens, 24), + '28': createOverflowSizeStyle(tokens, 28), + '32': createOverflowSizeStyle(tokens, 32), + '40': createOverflowSizeStyle(tokens, 40), + '56': createOverflowSizeStyle(tokens, 56), + '120': createOverflowSizeStyle(tokens, 120), + }), + avatarGroupOverflowStateLevels, +); + +const getThemedAvatarGroupOverflowTextStyle = getThemedStateStyleFactory( + 'AvatarGroup.overflowText', + (tokens: FlexTokens): StyleDefinition => ({ + color: tokens.color.foregroundNeutralPrimary, + fontFamily: tokens.fontFamily.functional, + fontWeight: tokens.fontWeight.functionalSemibold, + '16': createOverflowTextSizeStyle(tokens, 16), + '20': createOverflowTextSizeStyle(tokens, 20), + '24': createOverflowTextSizeStyle(tokens, 24), + '28': createOverflowTextSizeStyle(tokens, 28), + '32': createOverflowTextSizeStyle(tokens, 32), + '40': createOverflowTextSizeStyle(tokens, 40), + '56': createOverflowTextSizeStyle(tokens, 56), + '120': createOverflowTextSizeStyle(tokens, 120), + }), + avatarGroupOverflowStateLevels, +); + +function getSizeState(state: AvatarGroupState): string { + return String(state.size); +} + +export function getAvatarGroupRootStyle(state: AvatarGroupState): ViewStyle { + return getThemedAvatarGroupRootStyle(state, [getSizeState(state) as AvatarGroupRootState, state.layout]); +} + +export function getAvatarGroupItemStyle(state: AvatarGroupState): ViewStyle { + return getThemedAvatarGroupItemStyle(state, [getSizeState(state) as AvatarGroupItemState, state.layout]); +} + +export function getAvatarGroupItemOffsetStyle(state: AvatarGroupState): ViewStyle { + return getThemedAvatarGroupItemStyle(state, [getSizeState(state) as AvatarGroupItemState, state.layout, 'offset']); +} + +export function getAvatarGroupOverflowStyle(state: AvatarGroupState): ViewStyle { + return getThemedAvatarGroupOverflowStyle(state, [getSizeState(state) as AvatarGroupOverflowState]); +} + +export function getAvatarGroupOverflowTextStyle(state: AvatarGroupState): TextStyle { + return getThemedAvatarGroupOverflowTextStyle(state, [getSizeState(state) as AvatarGroupOverflowState]); +} diff --git a/packages/agentic/components/src/components/avatar-group/avatar-group.test.tsx b/packages/agentic/components/src/components/avatar-group/avatar-group.test.tsx new file mode 100644 index 0000000000..48a304e0e8 --- /dev/null +++ b/packages/agentic/components/src/components/avatar-group/avatar-group.test.tsx @@ -0,0 +1,296 @@ +/** @jsxImportSource @fluentui-react-native/framework-base */ +import { StyleSheet } from 'react-native'; +import type { TextStyle, ViewStyle } from 'react-native'; + +import { render } from '@testing-library/react-native'; +import type { RenderResult } from '@testing-library/react-native'; + +import { defaultFlexTokens } from '@fluentui-react-native/design/testing'; + +import { Avatar } from '../avatar/avatar'; +import { AvatarGroup } from './avatar-group'; +import type { AvatarGroupProps, AvatarGroupSize } from './avatar-group.types'; + +const tokens = defaultFlexTokens; +const sizes: readonly AvatarGroupSize[] = [16, 20, 24, 28, 32, 40, 56, 120]; +const spreadGaps: Record = { + 16: tokens.spacing.componentBase200, + 20: tokens.spacing.componentBase250, + 24: tokens.spacing.componentBase250, + 28: tokens.spacing.componentBase250, + 32: tokens.spacing.componentBase300, + 40: tokens.spacing.componentBase300, + 56: tokens.spacing.componentBase300, + 120: tokens.spacing.componentBase500, +}; +const ringWidths: Record = { + 16: Number(tokens.strokeWidth.thin), + 20: Number(tokens.strokeWidth.thin), + 24: Number(tokens.strokeWidth.thin), + 28: Number(tokens.strokeWidth.thin), + 32: Number(tokens.strokeWidth.thin), + 40: Number(tokens.strokeWidth.thick), + 56: Number(tokens.strokeWidth.thicker), + 120: 4, +}; +const overlaps: Record = { 16: 4, 20: 5, 24: 6, 28: 7, 32: 8, 40: 10, 56: 14, 120: 30 }; +const overflowFontSizes: Record = { + 16: tokens.fontSize.functionalCaption, + 20: tokens.fontSize.functionalCaption, + 24: tokens.fontSize.functionalCaption, + 28: tokens.fontSize.functionalBodySmall, + 32: tokens.fontSize.functionalBodyMedium, + 40: tokens.fontSize.functionalBodyLarge, + 56: tokens.fontSize.functionalTitleSmall, + 120: tokens.fontSize.functionalTitleLarge, +}; + +function renderGroup(props: AvatarGroupProps = {}): Promise { + const { children, ...rest } = props; + return render( + + {children ?? [ + , + , + ]} + , + ); +} + +function rootStyle(component: RenderResult): ViewStyle { + return StyleSheet.flatten(component.getByTestId('group', { includeHiddenElements: true }).props.style); +} + +function itemStyle(component: RenderResult, index: number): ViewStyle { + const avatar = component.getByTestId(`item-${index}`, { includeHiddenElements: true }); + return StyleSheet.flatten(avatar.parent?.props.style); +} + +function overflowStyle(component: RenderResult): ViewStyle { + return StyleSheet.flatten(component.getByTestId('overflow', { includeHiddenElements: true }).props.style); +} + +describe('AvatarGroup', () => { + let warn: jest.SpyInstance; + + beforeEach(() => { + warn = jest.spyOn(console, 'warn').mockImplementation(); + }); + + afterEach(() => { + warn.mockRestore(); + }); + + it('lays the children out as a spread row by default', async () => { + const component = await renderGroup(); + + expect(rootStyle(component)).toMatchObject({ + alignItems: 'center', + flexDirection: 'row', + gap: tokens.spacing.componentBase300, + }); + expect(component.getByText('LM', { includeHiddenElements: true })).toBeOnTheScreen(); + expect(component.getByText('RK', { includeHiddenElements: true })).toBeOnTheScreen(); + expect(component.queryByTestId('overflow')).toBeNull(); + expect(warn).not.toHaveBeenCalled(); + }); + + it('applies the spread gap and item box for every size', async () => { + for (const size of sizes) { + const component = await renderGroup({ size }); + + expect(rootStyle(component)).toMatchObject({ gap: spreadGaps[size] }); + expect(itemStyle(component, 0)).toMatchObject({ + backgroundColor: tokens.color.backgroundNeutralTransparent, + borderRadius: tokens.borderRadius.circular, + height: size, + width: size, + }); + expect(itemStyle(component, 0).marginStart).toBeUndefined(); + expect(itemStyle(component, 1)).toMatchObject({ marginStart: 0 }); + } + }); + + it('overlaps the items and paints a separation ring for every stacked size', async () => { + for (const size of sizes) { + const component = await renderGroup({ layout: 'stack', size }); + const ring = ringWidths[size]; + + expect(rootStyle(component)).toMatchObject({ gap: 0 }); + expect(itemStyle(component, 0)).toMatchObject({ + backgroundColor: tokens.color.surfaceNeutralNearer, + borderRadius: tokens.borderRadius.circular, + height: size + ring * 2, + width: size + ring * 2, + }); + expect(itemStyle(component, 0).marginStart).toBeUndefined(); + expect(itemStyle(component, 1)).toMatchObject({ marginStart: -(overlaps[size] + ring * 2) }); + } + }); + + it('keeps the first item flush and renders the items in source order', async () => { + const component = await renderGroup({ layout: 'stack' }); + const texts = component.getAllByText(/LM|RK/, { includeHiddenElements: true }).map((node) => node.props.children); + + expect(texts).toEqual(['LM', 'RK']); + expect(itemStyle(component, 0).marginStart).toBeUndefined(); + expect(itemStyle(component, 1).marginStart).toBe(-(overlaps[40] + ringWidths[40] * 2)); + }); + + it('renders the overflow indicator with the hidden count', async () => { + const component = await renderGroup({ overflow: { testID: 'overflow' }, overflowCount: 5 }); + + expect(component.getByText('+5', { includeHiddenElements: true })).toBeOnTheScreen(); + expect(overflowStyle(component)).toMatchObject({ + backgroundColor: tokens.color.surfaceNeutralNearer, + borderColor: tokens.color.strokeNeutralSubtle, + borderRadius: tokens.borderRadius.circular, + borderWidth: ringWidths[40], + height: 40, + width: 40, + }); + }); + + it('scales the overflow indicator across every size that renders it', async () => { + for (const size of sizes.filter((value) => value !== 16)) { + const component = await renderGroup({ overflow: { testID: 'overflow' }, overflowCount: 3, size }); + const textStyle: TextStyle = StyleSheet.flatten(component.getByText('+3', { includeHiddenElements: true }).props.style); + + expect(overflowStyle(component)).toMatchObject({ borderWidth: ringWidths[size], height: size, width: size }); + expect(textStyle).toMatchObject({ + color: tokens.color.foregroundNeutralPrimary, + fontFamily: tokens.fontFamily.functional, + fontSize: overflowFontSizes[size], + fontWeight: tokens.fontWeight.functionalSemibold, + lineHeight: overflowFontSizes[size], + }); + } + }); + + it('saturates the overflow indicator at ninety nine', async () => { + const component = await renderGroup({ overflowCount: 250 }); + + expect(component.getByText('+99', { includeHiddenElements: true })).toBeOnTheScreen(); + }); + + it('normalizes a fractional or negative overflow count', async () => { + expect((await renderGroup({ overflowCount: 4.7 })).getByText('+4', { includeHiddenElements: true })).toBeOnTheScreen(); + expect((await renderGroup({ overflowCount: -3 })).queryByText('+-3')).toBeNull(); + }); + + it('omits the overflow indicator at size sixteen and warns', async () => { + const component = await renderGroup({ overflowCount: 5, size: 16 }); + + expect(component.queryByText('+5', { includeHiddenElements: true })).toBeNull(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('size 16 omits the overflow indicator')); + }); + + it('honors an explicitly hidden overflow slot', async () => { + const component = await renderGroup({ overflow: null, overflowCount: 5 }); + + expect(component.queryByText('+5', { includeHiddenElements: true })).toBeNull(); + }); + + it('announces a labeled group once and hides its members', async () => { + const component = await renderGroup({ accessibilityLabel: 'Document collaborators: 8 people', overflowCount: 6 }); + const root = component.getByTestId('group'); + + expect(root.props.accessible).toBe(true); + expect(root.props.accessibilityRole).toBe('image'); + expect(root.props.accessibilityLabel).toBe('Document collaborators: 8 people'); + expect(component.getAllByRole('image')).toHaveLength(1); + }); + + it('leaves an unlabeled group as a plain row whose members announce themselves', async () => { + const component = await renderGroup(); + const root = component.getByTestId('group'); + + expect(root.props.accessible).toBe(false); + expect(root.props.accessibilityRole).toBe('none'); + expect(component.getAllByRole('image')).toHaveLength(2); + }); + + it('keeps the overflow indicator decorative until it is labeled', async () => { + const decorative = await renderGroup({ overflow: { testID: 'overflow' }, overflowCount: 5 }); + expect(decorative.getByTestId('overflow', { includeHiddenElements: true }).props.accessible).toBe(false); + + const labeled = await renderGroup({ overflow: { accessibilityLabel: '5 more', testID: 'overflow' }, overflowCount: 5 }); + const chip = labeled.getByTestId('overflow'); + expect(chip.props.accessible).toBe(true); + expect(chip.props.accessibilityRole).toBe('image'); + expect(chip.props.accessibilityLabel).toBe('5 more'); + }); + + it('honors an explicit accessible value and role', async () => { + const component = await renderGroup({ accessibilityRole: 'summary', accessible: true }); + const root = component.getByTestId('group'); + + expect(root.props.accessible).toBe(true); + expect(root.props.accessibilityRole).toBe('summary'); + }); + + it('forwards root view props and keeps user styles last', async () => { + const component = await renderGroup({ + accessibilityHint: 'Everyone on this thread', + accessibilityLabel: 'Thread participants', + nativeID: 'participants', + style: { flexDirection: 'column' }, + }); + const root = component.getByTestId('group'); + + expect(root.props.accessibilityHint).toBe('Everyone on this thread'); + expect(root.props.nativeID).toBe('participants'); + expect(rootStyle(component).flexDirection).toBe('column'); + }); + + it('adds no interaction handlers of its own', async () => { + const root = (await renderGroup()).getByTestId('group'); + + expect(root.props.onStartShouldSetResponder).toBeUndefined(); + expect(root.props.focusable).toBeUndefined(); + }); + + it('warns when a child avatar size disagrees with the group', async () => { + await renderGroup({ + children: [ + , + , + ], + size: 32, + }); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('same size as the group')); + }); + + it('accepts a matching child avatar size without warning', async () => { + await renderGroup({ + children: [ + , + , + ], + size: 32, + }); + + expect(warn).not.toHaveBeenCalled(); + }); + + it('warns when more than five items render', async () => { + await renderGroup({ + children: Array.from({ length: 5 }, (_unused, index) => ( + + )), + overflowCount: 4, + }); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('render at most 5 items')); + }); + + it('renders an overflow only group without a leading offset', async () => { + const component = await render(); + + expect(component.getByText('+2', { includeHiddenElements: true })).toBeOnTheScreen(); + expect( + StyleSheet.flatten(component.getByTestId('overflow', { includeHiddenElements: true }).parent?.props.style).marginStart, + ).toBeUndefined(); + }); +}); diff --git a/packages/agentic/components/src/components/avatar-group/avatar-group.ts b/packages/agentic/components/src/components/avatar-group/avatar-group.ts new file mode 100644 index 0000000000..5b9111c6cc --- /dev/null +++ b/packages/agentic/components/src/components/avatar-group/avatar-group.ts @@ -0,0 +1,14 @@ +import type { AvatarGroupProps } from './avatar-group.types'; +import { useAvatarGroup_unstable } from './useAvatarGroup'; +import { useAvatarGroupStyles_unstable } from './useAvatarGroupStyles'; +import { renderAvatarGroup_unstable } from './renderAvatarGroup'; + +export const AvatarGroup = (props: AvatarGroupProps) => { + const state = useAvatarGroup_unstable(props); + useAvatarGroupStyles_unstable(state); + return renderAvatarGroup_unstable(state); +}; + +AvatarGroup.displayName = 'AvatarGroup'; + +export default AvatarGroup; diff --git a/packages/agentic/components/src/components/avatar-group/avatar-group.types.test.ts b/packages/agentic/components/src/components/avatar-group/avatar-group.types.test.ts new file mode 100644 index 0000000000..bd8d145fb9 --- /dev/null +++ b/packages/agentic/components/src/components/avatar-group/avatar-group.types.test.ts @@ -0,0 +1,55 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import type { SlotProp } from '@fluentui-react-native/framework-base'; + +import type { AvatarGroup } from './avatar-group'; +import type { AvatarGroupLayout, AvatarGroupProps, AvatarGroupSize } from './avatar-group.types'; + +const DefaultAvatarGroupProps: AvatarGroupProps = {}; + +const SpreadAvatarGroupProps: AvatarGroupProps = { + accessibilityLabel: 'Document collaborators', + layout: 'spread', + size: 24, +}; + +const StackedOverflowAvatarGroupProps: AvatarGroupProps = { + layout: 'stack', + overflow: { accessibilityLabel: '5 more people', testID: 'overflow' }, + overflowCount: 5, + size: 56, +}; + +const HiddenOverflowAvatarGroupProps: AvatarGroupProps = { + overflow: null, + overflowCount: 5, +}; + +const StyledAvatarGroupProps: AvatarGroupProps = { + ref: null, + root: { accessibilityRole: 'summary' }, + style: { alignSelf: 'center' }, +}; + +const AvatarGroupSlot: SlotProp = { + layout: 'stack', + overflowCount: 2, +}; + +const layouts: readonly AvatarGroupLayout[] = ['spread', 'stack']; +const sizes: readonly AvatarGroupSize[] = [16, 20, 24, 28, 32, 40, 56, 120]; + +describe('AvatarGroup types', () => { + it('accepts the supported public slot and prop combinations', () => { + expect(DefaultAvatarGroupProps).toBeDefined(); + expect(SpreadAvatarGroupProps).toBeDefined(); + expect(StackedOverflowAvatarGroupProps).toBeDefined(); + expect(HiddenOverflowAvatarGroupProps).toBeDefined(); + expect(StyledAvatarGroupProps).toBeDefined(); + expect(AvatarGroupSlot).toBeDefined(); + }); + + it('declares the finite layout and size axes', () => { + expect(layouts).toHaveLength(2); + expect(sizes).toHaveLength(8); + }); +}); diff --git a/packages/agentic/components/src/components/avatar-group/avatar-group.types.ts b/packages/agentic/components/src/components/avatar-group/avatar-group.types.ts new file mode 100644 index 0000000000..7036407971 --- /dev/null +++ b/packages/agentic/components/src/components/avatar-group/avatar-group.types.ts @@ -0,0 +1,85 @@ +import type * as React from 'react'; +import type { StyleProp, View, ViewProps, ViewStyle } from 'react-native'; + +import type { + ComponentProps, + ComponentState, + OptionalSlot, + OwnedRootProps, + PropsWithRefOf, + Slot, +} from '@fluentui-react-native/framework-base'; +import type { ThemeState } from '@fluentui-react-native/design'; + +import type { AvatarSize } from '../avatar/avatar.types'; +import type { Text } from '../text/text'; + +export type AvatarGroupSize = AvatarSize; +export type AvatarGroupLayout = 'spread' | 'stack'; + +export type AvatarGroupSlots = { + root: Slot; + + /** + * The trailing `+N` indicator container. It renders only when `overflowCount` is positive and the group + * is larger than size `16`. + */ + overflow: OptionalSlot; +}; + +type AvatarGroupStateSlots = AvatarGroupSlots & { + overflowText: OptionalSlot; +}; + +export type AvatarGroupStateProps = { + /** + * How the items are positioned relative to each other. `spread` separates them with a size-scaled gap, + * and `stack` overlaps them and paints a size-scaled separation ring between them. + * + * @default spread + */ + layout?: AvatarGroupLayout; + + /** + * The number of members that are not rendered. A positive value appends the `+N` indicator. + * + * @default 0 + */ + overflowCount?: number; + + /** + * The diameter the group lays out for. It resolves the group's own geometry only, so each child Avatar + * still needs the same `size`. + * + * @default 40 + */ + size?: AvatarGroupSize; +}; + +export type AvatarGroupRootProps = OwnedRootProps>; + +export type AvatarGroupProps = AvatarGroupStateProps & + ComponentProps & { + /** The visible Avatar items, in the order they should be laid out. */ + children?: React.ReactNode; + }; + +export type AvatarGroupState = ComponentState & + Required & + ThemeState & { + children: React.ReactNode; + + /** Accessibility props applied to every item box while the root owns the group's accessible name. */ + itemAccessibilityProps?: ViewProps; + + /** Style applied to every item box after the first, resolved by `useAvatarGroupStyles_unstable`. */ + itemOffsetStyle?: StyleProp; + + /** Style applied to the box wrapping each item, resolved by `useAvatarGroupStyles_unstable`. */ + itemStyle?: StyleProp; + + /** The rendered indicator text, saturated at `+99`. Empty while no indicator renders. */ + overflowLabel: string; + + userStyle?: StyleProp; + }; diff --git a/packages/agentic/components/src/components/avatar-group/renderAvatarGroup.tsx b/packages/agentic/components/src/components/avatar-group/renderAvatarGroup.tsx new file mode 100644 index 0000000000..97f1173537 --- /dev/null +++ b/packages/agentic/components/src/components/avatar-group/renderAvatarGroup.tsx @@ -0,0 +1,29 @@ +/** @jsxImportSource @fluentui-react-native/framework-base */ +import * as React from 'react'; +import { View } from 'react-native'; + +import type { AvatarGroupState } from './avatar-group.types'; + +export function renderAvatarGroup_unstable(state: AvatarGroupState) { + const { itemAccessibilityProps, itemOffsetStyle, itemStyle, overflow: Overflow, overflowText: OverflowText } = state; + const items = React.Children.toArray(state.children); + + return ( + + {items.map((item, index) => ( + + {item} + + ))} + {Overflow && ( + + {OverflowText && } + + )} + + ); +} diff --git a/packages/agentic/components/src/components/avatar-group/spec/accessibility.md b/packages/agentic/components/src/components/avatar-group/spec/accessibility.md new file mode 100644 index 0000000000..95779dec06 --- /dev/null +++ b/packages/agentic/components/src/components/avatar-group/spec/accessibility.md @@ -0,0 +1,9 @@ +# AvatarGroup accessibility + +Give the group an `accessibilityLabel` when the cohort should announce once. The root then becomes accessible with the React Native image role, and the label should name the cohort and its total membership rather than the visible count, for example `Document collaborators: 8 people`. Windows maps that root to a UI Automation image and macOS maps it to an AX image. + +Leave `accessibilityLabel` off when each member matters on its own. The root then carries the `none` role, stays out of the accessibility tree as a control, and every Avatar child announces its own name in source order. Do not put the total count on both the group and its children; pick one place for it. Callers can still set `accessible` and `accessibilityRole` explicitly when a surface needs different semantics. + +The overflow indicator is decorative by default because `+5` announced as text loses its meaning. Give the `overflow` slot its own `accessibilityLabel`, such as `5 more`, when the group is unlabelled and the hidden count must still be heard; the indicator then becomes accessible with the image role. At size `16` the indicator is never rendered, so the hidden count has to live in the group's own label. + +AvatarGroup is not a control. It takes no focus, exposes no state, and adds no live region. When membership changes, the surrounding surface owns the announcement. diff --git a/packages/agentic/components/src/components/avatar-group/spec/interaction.md b/packages/agentic/components/src/components/avatar-group/spec/interaction.md new file mode 100644 index 0000000000..1e5bd79cc9 --- /dev/null +++ b/packages/agentic/components/src/components/avatar-group/spec/interaction.md @@ -0,0 +1,7 @@ +# AvatarGroup interaction + +AvatarGroup has no press, hover, disabled, selected, or focus state. It is not a tab stop, it renders no `FocusVisual`, and it adds no press handling to the Avatar children it lays out. Pointer and keyboard behaviour belong to a wrapping control when a roster needs to be activated, and that wrapper draws the focus ring around the whole group. + +Changing `layout`, `size`, `overflowCount`, or the child collection re-lays out the row without a component-owned animation, so no reduced-motion accommodation is required. The stack separation ring is a filled circular box rather than a border or an outline, so switching between layouts changes only geometry and fill and never introduces a border visual after mount. + +Rendered items paint in source order, which puts the trailing item, and therefore the overflow indicator, in front. The group does not flex, stretch, or compress: its width follows the declared size, the layout, and the number of rendered items. A surface that has to fit a narrower space should show fewer children and raise `overflowCount` rather than scale the group. diff --git a/packages/agentic/components/src/components/avatar-group/spec/source.json b/packages/agentic/components/src/components/avatar-group/spec/source.json new file mode 100644 index 0000000000..6c550bd558 --- /dev/null +++ b/packages/agentic/components/src/components/avatar-group/spec/source.json @@ -0,0 +1,120 @@ +{ + "schemaVersion": 2, + "component": "avatar-group", + "lifecycle": "implemented", + "conformance": "reviewed", + "reviewedAt": "2026-09-02", + "sources": [ + { + "id": "flex-component", + "kind": "flex-skill", + "authority": "normative", + "skill": "flex-components:avatar-group", + "sourceLock": "flex-1.5.0-206c4996", + "sourceLockFingerprint": "a69997212ec1b89510c94176801bf5a146ed7e7d8c80cc7db40ac8f60cf9f119", + "availableSurfaces": ["shared", "web"], + "surfacesConsulted": ["shared", "web"], + "sourceFiles": [ + { + "role": "skill", + "marketplacePath": "catalogs/flex/plugins/components/skills/avatar-group/SKILL.md", + "marketplaceBlobSha": "29fbe44d79098af76dac46666b0cf062e48aca20", + "marketplaceSha256": "1589a9a1962e1dfa47944c544fbb6b4fbd142eec112a7166f69cc160a9adf08a", + "originPath": "plugins/components/skills/avatar-group/SKILL.md", + "originBlobSha": "29fbe44d79098af76dac46666b0cf062e48aca20", + "originSha256": "1589a9a1962e1dfa47944c544fbb6b4fbd142eec112a7166f69cc160a9adf08a", + "contentDiffers": false + }, + { + "role": "usage", + "marketplacePath": "catalogs/flex/plugins/components/skills/avatar-group/usage.md", + "marketplaceBlobSha": "8c6c168695528721c9eab591edac828c93e0cac1", + "marketplaceSha256": "90405328d920f4731af098723b40cbc7cbd7a31db7df430d508cf3cd92a3ebad", + "originPath": "plugins/components/skills/avatar-group/usage.md", + "originBlobSha": "8c6c168695528721c9eab591edac828c93e0cac1", + "originSha256": "90405328d920f4731af098723b40cbc7cbd7a31db7df430d508cf3cd92a3ebad", + "contentDiffers": false + }, + { + "role": "web:accessibility", + "marketplacePath": "catalogs/flex/plugins/components/skills/avatar-group/web/accessibility.md", + "marketplaceBlobSha": "b292608a84f121943bc249d9971027ed8b2cc876", + "marketplaceSha256": "dbf078ded5ef1bbd7a881579e9d57c39932381c319ccaab4445e94a557be9592", + "originPath": "plugins/components/skills/avatar-group/web/accessibility.md", + "originBlobSha": "b292608a84f121943bc249d9971027ed8b2cc876", + "originSha256": "dbf078ded5ef1bbd7a881579e9d57c39932381c319ccaab4445e94a557be9592", + "contentDiffers": false + }, + { + "role": "web:interaction", + "marketplacePath": "catalogs/flex/plugins/components/skills/avatar-group/web/interaction.md", + "marketplaceBlobSha": "13aaa013506b84ae578ec5f325cc8cd698a18195", + "marketplaceSha256": "528042ebec29de86073b3de9cd45db07162cc673c107320faf38d2f91cdead48", + "originPath": "plugins/components/skills/avatar-group/web/interaction.md", + "originBlobSha": "13aaa013506b84ae578ec5f325cc8cd698a18195", + "originSha256": "528042ebec29de86073b3de9cd45db07162cc673c107320faf38d2f91cdead48", + "contentDiffers": false + }, + { + "role": "web:tokens", + "marketplacePath": "catalogs/flex/plugins/components/skills/avatar-group/web/tokens.yaml", + "marketplaceBlobSha": "6e493687a194ede7600befd8e53bf884606f5682", + "marketplaceSha256": "38dd10a0ea317cef6869a8dcad469701eb22eb89f7e2d0f92e0f7728aa258170", + "originPath": "plugins/components/skills/avatar-group/web/tokens.yaml", + "originBlobSha": "6e493687a194ede7600befd8e53bf884606f5682", + "originSha256": "38dd10a0ea317cef6869a8dcad469701eb22eb89f7e2d0f92e0f7728aa258170", + "contentDiffers": false + } + ], + "releaseDifferences": [] + } + ], + "divergences": [ + { + "id": "avatar-group-labeled-group-role", + "status": "accepted" + }, + { + "id": "avatar-group-overflow-not-an-avatar", + "status": "accepted" + }, + { + "id": "avatar-group-size-declared-on-group", + "status": "accepted" + }, + { + "id": "avatar-group-slot-maximum-advisory", + "status": "accepted" + }, + { + "id": "avatar-group-stack-separation-ring", + "status": "accepted" + } + ], + "requirements": [ + { + "id": "AVG-001", + "evidence": ["avatar-group.styles.ts", "useAvatarGroupStyles.ts", "renderAvatarGroup.tsx", "avatar-group.test.tsx"] + }, + { + "id": "AVG-002", + "evidence": ["avatar-group.types.ts", "useAvatarGroup.ts", "avatar-group.test.tsx", "avatar-group.types.test.ts"] + }, + { + "id": "AVG-003", + "evidence": ["useAvatarGroup.ts", "avatar-group.styles.ts", "avatar-group.test.tsx"] + }, + { + "id": "AVG-004", + "evidence": ["useAvatarGroup.ts", "useAvatarGroupStyles.ts", "avatar-group.test.tsx"] + }, + { + "id": "AVG-005", + "evidence": ["avatar-group.types.ts", "useAvatarGroupStyles.ts", "avatar-group.stories.tsx", "avatar-group.test.tsx"] + }, + { + "id": "AVG-006", + "evidence": ["useAvatarGroup.ts", "avatar-group.test.tsx"] + } + ] +} diff --git a/packages/agentic/components/src/components/avatar-group/spec/tokens.yaml b/packages/agentic/components/src/components/avatar-group/spec/tokens.yaml new file mode 100644 index 0000000000..3db7d345c2 --- /dev/null +++ b/packages/agentic/components/src/components/avatar-group/spec/tokens.yaml @@ -0,0 +1,94 @@ +schemaVersion: 1 +component: avatar-group +implementation: avatar-group.styles.ts + +statePrecedence: + - size + - layout + - offset + +bindings: + root: + allLayouts: + flexDirection: row + alignItems: center + spread: + gap: + '16': spacing.componentBase200 + '20': spacing.componentBase250 + '24': spacing.componentBase250 + '28': spacing.componentBase250 + '32': spacing.componentBase300 + '40': spacing.componentBase300 + '56': spacing.componentBase300 + '120': spacing.componentBase500 + stack: + gap: 0 + item: + allLayouts: + alignItems: center + justifyContent: center + borderRadius: borderRadius.circular + spread: + backgroundColor: color.backgroundNeutralTransparent + box: { '16': 16, '20': 20, '24': 24, '28': 28, '32': 32, '40': 40, '56': 56, '120': 120 } + stack: + backgroundColor: color.surfaceNeutralNearer + ringWidth: + '16': strokeWidth.thin + '20': strokeWidth.thin + '24': strokeWidth.thin + '28': strokeWidth.thin + '32': strokeWidth.thin + '40': strokeWidth.thick + '56': strokeWidth.thicker + '120': 4 + box: size + 2 * ringWidth + offset: + marginStart: -(overlap + 2 * ringWidth) + overlap: { '16': 4, '20': 5, '24': 6, '28': 7, '32': 8, '40': 10, '56': 14, '120': 30 } + overflow: + allSizes: + backgroundColor: color.surfaceNeutralNearer + borderColor: color.strokeNeutralSubtle + borderRadius: borderRadius.circular + alignment: center + size: + '16': { diameter: 16, borderWidth: strokeWidth.thin } + '20': { diameter: 20, borderWidth: strokeWidth.thin } + '24': { diameter: 24, borderWidth: strokeWidth.thin } + '28': { diameter: 28, borderWidth: strokeWidth.thin } + '32': { diameter: 32, borderWidth: strokeWidth.thin } + '40': { diameter: 40, borderWidth: strokeWidth.thick } + '56': { diameter: 56, borderWidth: strokeWidth.thicker } + '120': { diameter: 120, borderWidth: 4 } + overflowText: + color: color.foregroundNeutralPrimary + fontFamily: fontFamily.functional + fontWeight: fontWeight.functionalSemibold + 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 } + +delegated: + avatarChildren: > + Every visible item is a caller-supplied Avatar. Its background, foreground, + radius, padding, typography, and content tokens stay with Avatar and are not + redeclared or overridden here. + +tokenGaps: + - property: stack ring width at size 120 + values: [4] + reason: the stroke-width scale stops at 3, so the largest separation ring is an implementation constant. + - property: overflow border width at size 120 + values: [4] + reason: shares the stroke-width scale gap with the size 120 stack ring. + - property: stack overlap + values: [4, 5, 6, 7, 8, 10, 14, 30] + reason: the overlap is a quarter of each avatar diameter, which the spacing scale does not express at every stop. diff --git a/packages/agentic/components/src/components/avatar-group/spec/usage.md b/packages/agentic/components/src/components/avatar-group/spec/usage.md new file mode 100644 index 0000000000..ae6969b5b1 --- /dev/null +++ b/packages/agentic/components/src/components/avatar-group/spec/usage.md @@ -0,0 +1,11 @@ +# AvatarGroup usage + +Use AvatarGroup when several people or entities share one context and should read as a cohort: meeting participants, comment reactors, document collaborators, assignees on a row. Use `Avatar` directly for a single identity, and use a count or summary text for memberships in the tens or hundreds, where individual faces stop being scannable. + +Choose `spread` when each face has to read on its own, which suits small counts, comment headers, and reaction rows. Choose `stack` when the count is the message and horizontal space is tight, such as list rows, table cells, and headers. A stacked group paints its separation gaps in `color.surfaceNeutralNearer`, so place it on that surface. + +Set `size` on the group and give every child Avatar the same size. The group uses its own value only for spacing and for the overflow indicator, so a mismatch shows up as uneven geometry; development builds warn about it. Sizes `28` through `56` suit most groups. Size `16` never renders the indicator, so put the hidden count in the group's accessible name instead. Size `120` is accepted but reads as several separate portraits rather than a cohort. + +Keep the rendered items at five or fewer, counting the indicator, and move the remainder into `overflowCount`. Set `overflowCount` to the number of hidden members rather than the total, so a group of eight showing four members uses `overflowCount={4}`. + +Do not pad inside the group; spacing around it belongs to the containing surface. Do not nest one group inside another, do not mix sizes, and do not attach press handlers to individual children. When the whole roster should be actionable, wrap the group in a single interactive control and let that control own hover, pressed, focus, and target sizing. diff --git a/packages/agentic/components/src/components/avatar-group/useAvatarGroup.ts b/packages/agentic/components/src/components/avatar-group/useAvatarGroup.ts new file mode 100644 index 0000000000..c7e93baca7 --- /dev/null +++ b/packages/agentic/components/src/components/avatar-group/useAvatarGroup.ts @@ -0,0 +1,107 @@ +import * as React from 'react'; +import { View } from 'react-native'; + +import { useThemeState } from '@fluentui-react-native/design'; +import { useOptionalSlot, useSlot } from '@fluentui-react-native/framework-base'; + +import { hiddenFromAccessibilityProps } from '../../common/accessibility'; +import { Text } from '../text/text'; +import type { AvatarGroupProps, AvatarGroupState } from './avatar-group.types'; + +/** The number of rendered items past which the group stops reading as a scannable cohort. */ +const maximumRenderedItems = 5; + +/** The largest count the indicator can show before the exact total has to move into the accessible name. */ +const maximumOverflowCount = 99; + +function normalizeOverflowCount(value: number): number { + return Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0; +} + +function formatOverflowCount(count: number): string { + return `+${Math.min(count, maximumOverflowCount)}`; +} + +export function useAvatarGroup_unstable(props: AvatarGroupProps): AvatarGroupState { + const { + accessibilityLabel, + accessibilityRole, + accessible, + children, + layout = 'spread', + overflow: overflowProp, + overflowCount: overflowCountProp = 0, + size = 40, + style: userStyle, + ...rest + } = props; + + const overflowCount = normalizeOverflowCount(overflowCountProp); + const isInformative = accessibilityLabel !== undefined; + const isAccessible = accessible ?? isInformative; + const hasOverflow = overflowCount > 0; + const showOverflow = hasOverflow && size !== 16 && overflowProp !== null; + const overflowLabel = showOverflow ? formatOverflowCount(overflowCount) : ''; + + const { hasSizeMismatch, itemCount } = React.useMemo(() => { + let mismatch = false; + const items = React.Children.toArray(children); + for (const item of items) { + if (React.isValidElement<{ size?: unknown }>(item) && item.props.size !== undefined && item.props.size !== size) { + mismatch = true; + } + } + return { hasSizeMismatch: mismatch, itemCount: items.length }; + }, [children, size]); + + const renderedItems = itemCount + (showOverflow ? 1 : 0); + const suppressedOverflow = hasOverflow && size === 16; + + React.useEffect(() => { + if (!__DEV__) { + return; + } + if (hasSizeMismatch) { + console.warn('AvatarGroup: every child avatar should use the same size as the group.'); + } + if (suppressedOverflow) { + console.warn('AvatarGroup: size 16 omits the overflow indicator, so expose the hidden count in accessibilityLabel.'); + } + if (renderedItems > maximumRenderedItems) { + console.warn(`AvatarGroup: render at most ${maximumRenderedItems} items and move the rest into overflowCount.`); + } + }, [hasSizeMismatch, renderedItems, suppressedOverflow]); + + const themeState = useThemeState(); + const root = useSlot(View, { + ...rest, + accessible: isAccessible, + accessibilityLabel, + accessibilityRole: accessibilityRole ?? (isAccessible ? 'image' : 'none'), + }); + + const overflow = useOptionalSlot(View, showOverflow ? (overflowProp ?? {}) : null, { + transform: (slotProps) => { + const isSelfLabeled = !isAccessible && slotProps.accessibilityLabel !== undefined; + return isSelfLabeled + ? { ...slotProps, accessible: slotProps.accessible ?? true, accessibilityRole: slotProps.accessibilityRole ?? 'image' } + : { ...slotProps, ...hiddenFromAccessibilityProps }; + }, + }); + + const overflowText = useOptionalSlot(Text, showOverflow ? { children: overflowLabel } : null); + + return { + root, + overflow, + overflowText, + children, + itemAccessibilityProps: isAccessible ? hiddenFromAccessibilityProps : undefined, + layout, + overflowCount, + overflowLabel, + size, + userStyle, + ...themeState, + }; +} diff --git a/packages/agentic/components/src/components/avatar-group/useAvatarGroupStyles.ts b/packages/agentic/components/src/components/avatar-group/useAvatarGroupStyles.ts new file mode 100644 index 0000000000..c270b99fe4 --- /dev/null +++ b/packages/agentic/components/src/components/avatar-group/useAvatarGroupStyles.ts @@ -0,0 +1,32 @@ +import type { StyleProp, TextStyle, ViewStyle } from 'react-native'; + +import { attachSlotProps } from '@fluentui-react-native/framework-base'; + +import { hiddenFromAccessibilityProps } from '../../common/accessibility'; +import { + avatarGroupStyles, + getAvatarGroupItemOffsetStyle, + getAvatarGroupItemStyle, + getAvatarGroupOverflowStyle, + getAvatarGroupOverflowTextStyle, + getAvatarGroupRootStyle, +} from './avatar-group.styles'; +import type { AvatarGroupState } from './avatar-group.types'; + +export function useAvatarGroupStyles_unstable(state: AvatarGroupState) { + const rootStyle: StyleProp = [avatarGroupStyles.root, getAvatarGroupRootStyle(state), state.userStyle]; + + state.itemStyle = [avatarGroupStyles.item, getAvatarGroupItemStyle(state)]; + state.itemOffsetStyle = [avatarGroupStyles.item, getAvatarGroupItemOffsetStyle(state)]; + + attachSlotProps(state.root, { style: rootStyle }); + + if (state.overflow) { + const overflowStyle: StyleProp = [avatarGroupStyles.overflow, getAvatarGroupOverflowStyle(state)]; + attachSlotProps(state.overflow, { style: overflowStyle }); + } + if (state.overflowText) { + const overflowTextStyle: StyleProp = [avatarGroupStyles.overflowText, getAvatarGroupOverflowTextStyle(state)]; + attachSlotProps(state.overflowText, { ...hiddenFromAccessibilityProps, style: overflowTextStyle }); + } +} diff --git a/packages/agentic/components/src/index.test.ts b/packages/agentic/components/src/index.test.ts index 0776fe567d..2fbc9856b8 100644 --- a/packages/agentic/components/src/index.test.ts +++ b/packages/agentic/components/src/index.test.ts @@ -6,6 +6,7 @@ describe('component exports', () => { [ 'Accordion', 'Avatar', + 'AvatarGroup', 'Badge', 'Button', 'Card', @@ -25,6 +26,7 @@ describe('component exports', () => { 'Tag', 'Text', 'renderAccordion_unstable', + 'renderAvatarGroup_unstable', 'renderAvatar_unstable', 'renderBadge_unstable', 'renderButton_unstable', @@ -46,6 +48,8 @@ describe('component exports', () => { 'renderText_unstable', 'useAccordionStyles_unstable', 'useAccordion_unstable', + 'useAvatarGroupStyles_unstable', + 'useAvatarGroup_unstable', 'useAvatarStyles_unstable', 'useAvatar_unstable', 'useBadgeStyles_unstable', diff --git a/packages/agentic/components/src/index.ts b/packages/agentic/components/src/index.ts index c142a2d1f5..5fa8f90c6b 100644 --- a/packages/agentic/components/src/index.ts +++ b/packages/agentic/components/src/index.ts @@ -16,6 +16,18 @@ export { renderAvatar_unstable } from './components/avatar/renderAvatar'; export { useAvatarStyles_unstable } from './components/avatar/useAvatarStyles'; export { useAvatar_unstable } from './components/avatar/useAvatar'; +export { AvatarGroup } from './components/avatar-group/avatar-group'; +export type { + AvatarGroupLayout, + AvatarGroupProps, + AvatarGroupSize, + AvatarGroupSlots, + AvatarGroupState, +} from './components/avatar-group/avatar-group.types'; +export { renderAvatarGroup_unstable } from './components/avatar-group/renderAvatarGroup'; +export { useAvatarGroupStyles_unstable } from './components/avatar-group/useAvatarGroupStyles'; +export { useAvatarGroup_unstable } from './components/avatar-group/useAvatarGroup'; + export { Badge } from './components/badge/badge'; export type { BadgeAppearance, From c917c7c1ba01ba26a2b84818f549a664a57fcaf2 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Wed, 2 Sep 2026 16:58:08 -0700 Subject: [PATCH 02/22] fix(agentic): address avatar group review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../avatar-group/avatar-group.test.tsx | 37 +++++++++++++ .../avatar-group/avatar-group.types.ts | 3 +- .../avatar-group/renderAvatarGroup.tsx | 12 +--- .../components/avatar-group/useAvatarGroup.ts | 55 ++++++++++++++++--- 4 files changed, 89 insertions(+), 18 deletions(-) diff --git a/packages/agentic/components/src/components/avatar-group/avatar-group.test.tsx b/packages/agentic/components/src/components/avatar-group/avatar-group.test.tsx index 48a304e0e8..972204b892 100644 --- a/packages/agentic/components/src/components/avatar-group/avatar-group.test.tsx +++ b/packages/agentic/components/src/components/avatar-group/avatar-group.test.tsx @@ -137,6 +137,23 @@ describe('AvatarGroup', () => { expect(itemStyle(component, 1).marginStart).toBe(-(overlaps[40] + ringWidths[40] * 2)); }); + it('recursively flattens fragments into independently laid out items', async () => { + const component = await renderGroup({ + children: ( + <> + + <> + + + + ), + layout: 'stack', + }); + + expect(itemStyle(component, 0).marginStart).toBeUndefined(); + expect(itemStyle(component, 1).marginStart).toBe(-(overlaps[40] + ringWidths[40] * 2)); + }); + it('renders the overflow indicator with the hidden count', async () => { const component = await renderGroup({ overflow: { testID: 'overflow' }, overflowCount: 5 }); @@ -201,6 +218,17 @@ describe('AvatarGroup', () => { expect(component.getAllByRole('image')).toHaveLength(1); }); + it.each([{ accessibilityLabelledBy: 'group-label' }, { 'aria-label': 'Document collaborators' }, { 'aria-labelledby': 'group-label' }])( + 'recognizes every supported programmatic group name', + async (nameProps) => { + const component = await renderGroup(nameProps); + const root = component.getByTestId('group'); + + expect(root.props.accessible).toBe(true); + expect(component.getAllByRole('image')).toHaveLength(1); + }, + ); + it('leaves an unlabeled group as a plain row whose members announce themselves', async () => { const component = await renderGroup(); const root = component.getByTestId('group'); @@ -221,6 +249,15 @@ describe('AvatarGroup', () => { expect(chip.props.accessibilityLabel).toBe('5 more'); }); + it('recognizes a referenced accessible name on the overflow indicator', async () => { + const component = await renderGroup({ + overflow: { accessibilityLabelledBy: 'overflow-label', testID: 'overflow' }, + overflowCount: 5, + }); + + expect(component.getByTestId('overflow').props.accessible).toBe(true); + }); + it('honors an explicit accessible value and role', async () => { const component = await renderGroup({ accessibilityRole: 'summary', accessible: true }); const root = component.getByTestId('group'); diff --git a/packages/agentic/components/src/components/avatar-group/avatar-group.types.ts b/packages/agentic/components/src/components/avatar-group/avatar-group.types.ts index 7036407971..6e6643b6d3 100644 --- a/packages/agentic/components/src/components/avatar-group/avatar-group.types.ts +++ b/packages/agentic/components/src/components/avatar-group/avatar-group.types.ts @@ -67,7 +67,8 @@ export type AvatarGroupProps = AvatarGroupStateProps & export type AvatarGroupState = ComponentState & Required & ThemeState & { - children: React.ReactNode; + /** The recursively flattened sequence of visible items and their stable wrapper keys. */ + items: readonly { key: React.Key; node: React.ReactNode }[]; /** Accessibility props applied to every item box while the root owns the group's accessible name. */ itemAccessibilityProps?: ViewProps; diff --git a/packages/agentic/components/src/components/avatar-group/renderAvatarGroup.tsx b/packages/agentic/components/src/components/avatar-group/renderAvatarGroup.tsx index 97f1173537..67cef3c550 100644 --- a/packages/agentic/components/src/components/avatar-group/renderAvatarGroup.tsx +++ b/packages/agentic/components/src/components/avatar-group/renderAvatarGroup.tsx @@ -1,22 +1,16 @@ /** @jsxImportSource @fluentui-react-native/framework-base */ -import * as React from 'react'; import { View } from 'react-native'; import type { AvatarGroupState } from './avatar-group.types'; export function renderAvatarGroup_unstable(state: AvatarGroupState) { - const { itemAccessibilityProps, itemOffsetStyle, itemStyle, overflow: Overflow, overflowText: OverflowText } = state; - const items = React.Children.toArray(state.children); + const { itemAccessibilityProps, itemOffsetStyle, itemStyle, items, overflow: Overflow, overflowText: OverflowText } = state; return ( {items.map((item, index) => ( - - {item} + + {item.node} ))} {Overflow && ( diff --git a/packages/agentic/components/src/components/avatar-group/useAvatarGroup.ts b/packages/agentic/components/src/components/avatar-group/useAvatarGroup.ts index c7e93baca7..bc162bdefa 100644 --- a/packages/agentic/components/src/components/avatar-group/useAvatarGroup.ts +++ b/packages/agentic/components/src/components/avatar-group/useAvatarGroup.ts @@ -22,9 +22,40 @@ function formatOverflowCount(count: number): string { return `+${Math.min(count, maximumOverflowCount)}`; } +function flattenItems(children: React.ReactNode, keyPrefix = ''): { key: React.Key; node: React.ReactNode }[] { + const items: { key: React.Key; node: React.ReactNode }[] = []; + React.Children.forEach(children, (child, index) => { + const childKey = React.isValidElement(child) && child.key !== null ? child.key : index; + const key = keyPrefix ? `${keyPrefix}/${String(childKey)}` : childKey; + if (React.isValidElement<{ children?: React.ReactNode }>(child) && child.type === React.Fragment) { + items.push(...flattenItems(child.props.children, String(key))); + } else { + items.push({ key, node: child }); + } + }); + return items; +} + +function hasAccessibleName(props: { + 'aria-label'?: string; + 'aria-labelledby'?: string; + accessibilityLabel?: string; + accessibilityLabelledBy?: string; +}): boolean { + return ( + props.accessibilityLabel !== undefined || + props.accessibilityLabelledBy !== undefined || + props['aria-label'] !== undefined || + props['aria-labelledby'] !== undefined + ); +} + export function useAvatarGroup_unstable(props: AvatarGroupProps): AvatarGroupState { const { + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, accessibilityLabel, + accessibilityLabelledBy, accessibilityRole, accessible, children, @@ -37,24 +68,29 @@ export function useAvatarGroup_unstable(props: AvatarGroupProps): AvatarGroupSta } = props; const overflowCount = normalizeOverflowCount(overflowCountProp); - const isInformative = accessibilityLabel !== undefined; + const isInformative = hasAccessibleName({ + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, + accessibilityLabel, + accessibilityLabelledBy, + }); const isAccessible = accessible ?? isInformative; const hasOverflow = overflowCount > 0; const showOverflow = hasOverflow && size !== 16 && overflowProp !== null; const overflowLabel = showOverflow ? formatOverflowCount(overflowCount) : ''; - const { hasSizeMismatch, itemCount } = React.useMemo(() => { + const { hasSizeMismatch, items } = React.useMemo(() => { let mismatch = false; - const items = React.Children.toArray(children); - for (const item of items) { + const resolvedItems = flattenItems(children); + for (const { node: item } of resolvedItems) { if (React.isValidElement<{ size?: unknown }>(item) && item.props.size !== undefined && item.props.size !== size) { mismatch = true; } } - return { hasSizeMismatch: mismatch, itemCount: items.length }; + return { hasSizeMismatch: mismatch, items: resolvedItems }; }, [children, size]); - const renderedItems = itemCount + (showOverflow ? 1 : 0); + const renderedItems = items.length + (showOverflow ? 1 : 0); const suppressedOverflow = hasOverflow && size === 16; React.useEffect(() => { @@ -75,14 +111,17 @@ export function useAvatarGroup_unstable(props: AvatarGroupProps): AvatarGroupSta const themeState = useThemeState(); const root = useSlot(View, { ...rest, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, accessible: isAccessible, accessibilityLabel, + accessibilityLabelledBy, accessibilityRole: accessibilityRole ?? (isAccessible ? 'image' : 'none'), }); const overflow = useOptionalSlot(View, showOverflow ? (overflowProp ?? {}) : null, { transform: (slotProps) => { - const isSelfLabeled = !isAccessible && slotProps.accessibilityLabel !== undefined; + const isSelfLabeled = !isAccessible && hasAccessibleName(slotProps); return isSelfLabeled ? { ...slotProps, accessible: slotProps.accessible ?? true, accessibilityRole: slotProps.accessibilityRole ?? 'image' } : { ...slotProps, ...hiddenFromAccessibilityProps }; @@ -95,7 +134,7 @@ export function useAvatarGroup_unstable(props: AvatarGroupProps): AvatarGroupSta root, overflow, overflowText, - children, + items, itemAccessibilityProps: isAccessible ? hiddenFromAccessibilityProps : undefined, layout, overflowCount, From ba124d9857a020a2d49e72c0c228684dae509d3c Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Wed, 2 Sep 2026 16:23:15 -0700 Subject: [PATCH 03/22] feat(components): add the agentic DestructiveButton component Add DestructiveButton as a distinct component for irreversible and high-consequence actions rather than widening Button with a danger appearance. Keeping it separate lets the destructive surface drop the axes that do not apply to it: there is no selection model, no square shape, and no neutral emphasis level, so a destructive action can never be authored as a toggle or mistaken for a neutral square Button. The contract was authored and explicitly reviewed against the pinned Flex source (flex-1.5.0-206c4996) before any code, and records three divergences: the icon-only default shape, the mobile-only Secondary style, and FURN's single icon slot with iconPosition. Only icon sizing is reused from Button, since the source states it inherits; structural padding and radius are restated locally because Button's style factory is keyed by a square shape and a selection-bearing state shape that DestructiveButton does not have. The default token set maps no distinct hover or pressed value onto the loud danger background, so the primary appearance currently shows no background change on interaction. That gap is recorded in spec/tokens.yaml and spec/interaction.md rather than papered over. Resolves #4221 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/tidy-moons-delete.md | 5 + .../components/spec-source-report.json | 102 +++++- .../src/components/destructive-button/SPEC.md | 132 ++++++++ .../destructive-button.test.tsx.snap | 306 +++++++++++++++++ .../destructive-button.stories.tsx | 312 ++++++++++++++++++ .../destructive-button.styles.ts | 240 ++++++++++++++ .../destructive-button.test.tsx | 305 +++++++++++++++++ .../destructive-button/destructive-button.ts | 17 + .../destructive-button.types.test.tsx | 46 +++ .../destructive-button.types.ts | 93 ++++++ .../renderDestructiveButton.tsx | 21 ++ .../destructive-button/spec/accessibility.md | 61 ++++ .../destructive-button/spec/interaction.md | 52 +++ .../destructive-button/spec/source.json | 172 ++++++++++ .../destructive-button/spec/tokens.yaml | 132 ++++++++ .../destructive-button/spec/usage.md | 60 ++++ .../useDestructiveButton.ts | 79 +++++ .../useDestructiveButtonStyles.ts | 49 +++ packages/agentic/components/src/index.test.ts | 4 + packages/agentic/components/src/index.ts | 14 + .../components/src/refs.types.test.tsx | 2 + 21 files changed, 2187 insertions(+), 17 deletions(-) create mode 100644 .changeset/tidy-moons-delete.md create mode 100644 packages/agentic/components/src/components/destructive-button/SPEC.md create mode 100644 packages/agentic/components/src/components/destructive-button/__snapshots__/destructive-button.test.tsx.snap create mode 100644 packages/agentic/components/src/components/destructive-button/destructive-button.stories.tsx create mode 100644 packages/agentic/components/src/components/destructive-button/destructive-button.styles.ts create mode 100644 packages/agentic/components/src/components/destructive-button/destructive-button.test.tsx create mode 100644 packages/agentic/components/src/components/destructive-button/destructive-button.ts create mode 100644 packages/agentic/components/src/components/destructive-button/destructive-button.types.test.tsx create mode 100644 packages/agentic/components/src/components/destructive-button/destructive-button.types.ts create mode 100644 packages/agentic/components/src/components/destructive-button/renderDestructiveButton.tsx create mode 100644 packages/agentic/components/src/components/destructive-button/spec/accessibility.md create mode 100644 packages/agentic/components/src/components/destructive-button/spec/interaction.md create mode 100644 packages/agentic/components/src/components/destructive-button/spec/source.json create mode 100644 packages/agentic/components/src/components/destructive-button/spec/tokens.yaml create mode 100644 packages/agentic/components/src/components/destructive-button/spec/usage.md create mode 100644 packages/agentic/components/src/components/destructive-button/useDestructiveButton.ts create mode 100644 packages/agentic/components/src/components/destructive-button/useDestructiveButtonStyles.ts diff --git a/.changeset/tidy-moons-delete.md b/.changeset/tidy-moons-delete.md new file mode 100644 index 0000000000..544b67c8a2 --- /dev/null +++ b/.changeset/tidy-moons-delete.md @@ -0,0 +1,5 @@ +--- +"@fluentui-react-native/components": minor +--- + +Add the DestructiveButton component for irreversible and high-consequence actions, with primary and subtle danger appearances, three sizes, rounded and circle shapes, and a leading or trailing icon slot. diff --git a/packages/agentic/components/spec-source-report.json b/packages/agentic/components/spec-source-report.json index 37e1bfe65c..46435f8246 100644 --- a/packages/agentic/components/spec-source-report.json +++ b/packages/agentic/components/spec-source-report.json @@ -119,7 +119,11 @@ "toolbar", "tooltip" ], - "added": ["persona", "presence-badge", "toast"], + "added": [ + "persona", + "presence-badge", + "toast" + ], "removed": [] }, "local": { @@ -131,6 +135,7 @@ "button", "card", "checkbox", + "destructive-button", "divider", "input", "list-item", @@ -155,6 +160,7 @@ "button", "card", "checkbox", + "destructive-button", "divider", "input", "list-item", @@ -173,7 +179,6 @@ "implementationGap": [ "breadcrumb", "combobox", - "destructive-button", "dialog", "drawer", "dropdown", @@ -204,7 +209,6 @@ "noLocalContract": [ "breadcrumb", "combobox", - "destructive-button", "dialog", "drawer", "dropdown", @@ -447,7 +451,11 @@ "originDrift": { "added": [], "removed": [], - "modified": ["web/accessibility.md", "web/interaction.md", "web/tokens.yaml"] + "modified": [ + "web/accessibility.md", + "web/interaction.md", + "web/tokens.yaml" + ] }, "candidateStatus": "review-required" }, @@ -498,7 +506,13 @@ "originDrift": { "added": [], "removed": [], - "modified": ["SKILL.md", "usage.md", "web/accessibility.md", "web/interaction.md", "web/tokens.yaml"] + "modified": [ + "SKILL.md", + "usage.md", + "web/accessibility.md", + "web/interaction.md", + "web/tokens.yaml" + ] }, "candidateStatus": "review-required" }, @@ -515,7 +529,11 @@ "originDrift": { "added": [], "removed": [], - "modified": ["web/accessibility.md", "web/interaction.md", "web/tokens.yaml"] + "modified": [ + "web/accessibility.md", + "web/interaction.md", + "web/tokens.yaml" + ] }, "candidateStatus": "review-required" }, @@ -532,7 +550,10 @@ "originDrift": { "added": [], "removed": [], - "modified": ["web/accessibility.md", "web/interaction.md"] + "modified": [ + "web/accessibility.md", + "web/interaction.md" + ] }, "candidateStatus": "review-required" }, @@ -549,7 +570,29 @@ "originDrift": { "added": [], "removed": [], - "modified": ["web/accessibility.md", "web/interaction.md"] + "modified": [ + "web/accessibility.md", + "web/interaction.md" + ] + }, + "candidateStatus": "review-required" + }, + { + "component": "destructive-button", + "lifecycle": "implemented", + "conformance": "reviewed", + "releaseDifferences": [], + "marketplaceDrift": { + "added": [], + "removed": [], + "modified": [] + }, + "originDrift": { + "added": [], + "removed": [], + "modified": [ + "web/interaction.md" + ] }, "candidateStatus": "review-required" }, @@ -590,7 +633,9 @@ "originDrift": { "added": [], "removed": [], - "modified": ["web/accessibility.md"] + "modified": [ + "web/accessibility.md" + ] }, "candidateStatus": "review-required" }, @@ -607,7 +652,10 @@ "originDrift": { "added": [], "removed": [], - "modified": ["web/accessibility.md", "web/interaction.md"] + "modified": [ + "web/accessibility.md", + "web/interaction.md" + ] }, "candidateStatus": "review-required" }, @@ -624,7 +672,10 @@ "originDrift": { "added": [], "removed": [], - "modified": ["web/accessibility.md", "web/interaction.md"] + "modified": [ + "web/accessibility.md", + "web/interaction.md" + ] }, "candidateStatus": "review-required" }, @@ -641,7 +692,10 @@ "originDrift": { "added": [], "removed": [], - "modified": ["web/accessibility.md", "web/interaction.md"] + "modified": [ + "web/accessibility.md", + "web/interaction.md" + ] }, "candidateStatus": "review-required" }, @@ -658,7 +712,9 @@ "originDrift": { "added": [], "removed": [], - "modified": ["web/tokens.yaml"] + "modified": [ + "web/tokens.yaml" + ] }, "candidateStatus": "review-required" }, @@ -675,7 +731,9 @@ "originDrift": { "added": [], "removed": [], - "modified": ["web/interaction.md"] + "modified": [ + "web/interaction.md" + ] }, "candidateStatus": "review-required" }, @@ -726,7 +784,10 @@ "originDrift": { "added": [], "removed": [], - "modified": ["web/interaction.md", "web/tokens.yaml"] + "modified": [ + "web/interaction.md", + "web/tokens.yaml" + ] }, "candidateStatus": "review-required" }, @@ -743,7 +804,11 @@ "originDrift": { "added": [], "removed": [], - "modified": ["web/accessibility.md", "web/interaction.md", "web/tokens.yaml"] + "modified": [ + "web/accessibility.md", + "web/interaction.md", + "web/tokens.yaml" + ] }, "candidateStatus": "review-required" }, @@ -777,7 +842,10 @@ "originDrift": { "added": [], "removed": [], - "modified": ["web/accessibility.md", "web/interaction.md"] + "modified": [ + "web/accessibility.md", + "web/interaction.md" + ] }, "candidateStatus": "review-required" }, diff --git a/packages/agentic/components/src/components/destructive-button/SPEC.md b/packages/agentic/components/src/components/destructive-button/SPEC.md new file mode 100644 index 0000000000..be762cd4a6 --- /dev/null +++ b/packages/agentic/components/src/components/destructive-button/SPEC.md @@ -0,0 +1,132 @@ +--- +name: destructive-button +platform: react-native (Windows, macOS) +status: implemented +source: ./spec/source.json +tokens: ./spec/tokens.yaml +accessibility: ./spec/accessibility.md +interaction: ./spec/interaction.md +usage: ./spec/usage.md +--- + +# DestructiveButton + +## Scope + +DestructiveButton presents a single irreversible or high-consequence action +through a React Native `Pressable` on Windows and macOS. It carries the danger +color family so the control itself signals loss, deletion, or another outcome +that is hard to reverse. + +DestructiveButton is a distinct component rather than a widened Button +appearance. The catalog entry trims the emphasis axis to two values, removes +the selection axis entirely, and rebinds rest, hovered, and pressed color to +the danger family. Modelling that as a Button appearance would leave Button +carrying a selection axis and two shape values that the destructive contract +must not expose. + +DestructiveButton is not a toggle, a link, a menu trigger, or a confirmation +surface. It does not gate its own activation; a caller that needs confirmation +owns that dialog. + +## Public contract + +### Props and defaults + +| Prop | Type | Default | Contract | +| -------------- | -------------------------- | ----------------------------------------------- | ----------------------------------------------------------- | +| `appearance` | `primary \| subtle` | `primary` | Selects the danger emphasis level. | +| `size` | `small \| medium \| large` | `medium` | Selects typography, icon size, spacing, and rounded radius. | +| `shape` | `rounded \| circle` | `rounded` with content; `circle` when icon-only | Controls the root corner radius. | +| `disabled` | `boolean` | `false` | Disables activation and removes the root from focus. | +| `iconPosition` | `before \| after` | `before` | Places the icon relative to content. | + +The source declares defaults for shape and size but not for the emphasis axis. +This contract resolves `appearance` to `primary` because the axis is ordered by +descending emphasis, `primary` is its highest value, and the canonical use is +the confirm action of a delete or discard flow. Button's `secondary` default has +no counterpart in a two-value danger axis. + +The root also exposes owned `PressableProps`, except children and styles that +the component resolves itself. A user `style` is applied after token-derived +root styles. + +### Slots and anatomy + +The render order is the persistent focus visual, the icon when positioned +before, content, and the icon when positioned after. + +| Slot | Required | Contract | +| --------- | -------- | --------------------------------------------------------------- | +| `root` | yes | A `Pressable` that owns action semantics and interaction state. | +| `content` | no | A `Text` slot. It may wrap when the root width is constrained. | +| `icon` | no | An `Icon` slot. It is hidden from the accessibility tree. | + +Both slots are optional in the type system so a caller can build either +documented layout. An icon-only button has an icon and no content; it keeps a +minimum 24 by 24 layout and requires an action-oriented `accessibilityLabel`. + +The component does not supply a default label. The source default of "Delete" +is design-tool authoring state, not a runtime guarantee, and inventing a +destructive verb for a caller who omitted `content` would be unsafe. + +### Requirements + +- **DBTN-001:** Resolve the documented defaults, including the contextual + icon-only shape, and preserve supported native root props. +- **DBTN-002:** Render only supplied optional slots in the documented order and + allow content to wrap under a constrained root. +- **DBTN-003:** Resolve appearance, disabled, pressed, and hovered visuals from + the Flex danger token family, keep both appearances strokeless, and apply the + user root style last. +- **DBTN-004:** Expose button semantics, merge caller accessibility state, warn + for an unnamed icon-only button, and hide the decorative icon. +- **DBTN-005:** Expose no selection axis. The public props admit neither + `selected` nor `selectedIcon`, and the root never reports checked state. +- **DBTN-006:** Keep the dual-ring `FocusVisual` mounted and show it only for a + focused, enabled button while disabling the native Windows focus ring. + +## Platform behavior + +Windows and macOS use React Native press, hover, and focus events. `Enter` and +`Space` activation are supplied by the native `Pressable` button behavior. +Disabled buttons are not focusable. + +React Native Windows native focus visuals are disabled because dynamically +mounting its border visual can crash supported RNW versions. The component +keeps the shared dual-ring `FocusVisual` mounted and changes only its +visibility state. The contract adds no motion; visual state changes are +immediate, so reduced-motion handling needs no separate branch. + +## Reuse boundary + +DestructiveButton owns a full component-qualified pipeline: its own state, +style, and render stages exported under `useDestructiveButton_unstable`, +`useDestructiveButtonStyles_unstable`, and `renderDestructiveButton_unstable`. +It shares Button's primitives (`Text`, `Icon`, `FocusVisual`) and reuses +`getButtonIconSize` because the source states that icon sizing inherits from +Button, so the two components must not drift apart. + +Structural spacing and radius values are restated locally rather than reused +from Button's factory. Button's factory is keyed by a shape axis that includes +`square`, and reusing it would require DestructiveButton to satisfy a state +shape carrying Button's selection axis. Button is left unchanged. + +## Divergences from Flex + +| ID | Disposition | React Native contract | Follow-up | +| ------------------------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| `destructive-button-single-icon-slot` | Deferred alignment | FURN has one `icon` plus `iconPosition`. The source has independent leading and trailing icon slots that can both be visible. | Align with Button through one separately reviewed public API change. | +| `destructive-button-icon-only-shape` | Accepted local extension | An unspecified `shape` resolves to `circle` for an icon-only button instead of the source default of rounded, matching Button and the source's conventional pairing. | Preserve while the button family shares one shape-resolution behavior. | +| `destructive-button-mobile-secondary` | Not applicable | The mobile surface adds a third `Secondary` style, drops the shape axis, and uses a different icon-size ramp. This contract targets Windows and macOS. | Revisit only if this package targets iOS or Android. | + +## Conformance + +| Requirement | Evidence | +| ----------- | ------------------------------------------------------------------------------------------------- | +| DBTN-001 | `destructive-button.types.ts`, `useDestructiveButton.ts`, `destructive-button.test.tsx` | +| DBTN-002 | `renderDestructiveButton.tsx`, `destructive-button.test.tsx`, `destructive-button.stories.tsx` | +| DBTN-003 | `destructive-button.styles.ts`, `useDestructiveButtonStyles.ts`, `destructive-button.test.tsx` | +| DBTN-004 | `useDestructiveButton.ts`, `useDestructiveButtonStyles.ts`, `destructive-button.test.tsx` | +| DBTN-005 | `destructive-button.types.ts`, `destructive-button.types.test.tsx`, `destructive-button.test.tsx` | +| DBTN-006 | `useDestructiveButtonStyles.ts`, `renderDestructiveButton.tsx`, `destructive-button.test.tsx` | diff --git a/packages/agentic/components/src/components/destructive-button/__snapshots__/destructive-button.test.tsx.snap b/packages/agentic/components/src/components/destructive-button/__snapshots__/destructive-button.test.tsx.snap new file mode 100644 index 0000000000..54dbab5883 --- /dev/null +++ b/packages/agentic/components/src/components/destructive-button/__snapshots__/destructive-button.test.tsx.snap @@ -0,0 +1,306 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DestructiveButton matches the disabled visual state snapshot across appearances 1`] = ` +[ + { + "appearance": "primary", + "contentStyle": { + "color": "#bdbdbd", + "flexShrink": 1, + "fontFamily": "Helvetica Neue", + "fontSize": 14, + "fontWeight": "400", + "lineHeight": 20, + "textAlign": "center", + }, + "rootStyle": { + "alignItems": "center", + "alignSelf": "flex-start", + "backgroundColor": "#f0f0f0", + "borderColor": "#00000000", + "borderRadius": 4, + "borderStyle": "solid", + "borderWidth": 1, + "flexDirection": "row", + "gap": 4, + "justifyContent": "center", + "minHeight": 24, + "minWidth": 24, + "paddingHorizontal": 10, + "paddingVertical": 6, + }, + }, + { + "appearance": "subtle", + "contentStyle": { + "color": "#bdbdbd", + "flexShrink": 1, + "fontFamily": "Helvetica Neue", + "fontSize": 14, + "fontWeight": "400", + "lineHeight": 20, + "textAlign": "center", + }, + "rootStyle": { + "alignItems": "center", + "alignSelf": "flex-start", + "backgroundColor": "#00000000", + "borderColor": "#00000000", + "borderRadius": 4, + "borderStyle": "solid", + "borderWidth": 1, + "flexDirection": "row", + "gap": 4, + "justifyContent": "center", + "minHeight": 24, + "minWidth": 24, + "paddingHorizontal": 10, + "paddingVertical": 6, + }, + }, +] +`; + +exports[`DestructiveButton matches the focused visual state snapshot across appearances 1`] = ` +[ + { + "appearance": "primary", + "contentStyle": { + "color": "#ffffff", + "flexShrink": 1, + "fontFamily": "Helvetica Neue", + "fontSize": 14, + "fontWeight": "400", + "lineHeight": 20, + "textAlign": "center", + }, + "rootStyle": { + "alignItems": "center", + "alignSelf": "flex-start", + "backgroundColor": "#d13438", + "borderColor": "#00000000", + "borderRadius": 4, + "borderStyle": "solid", + "borderWidth": 1, + "flexDirection": "row", + "gap": 4, + "justifyContent": "center", + "minHeight": 24, + "minWidth": 24, + "paddingHorizontal": 10, + "paddingVertical": 6, + }, + }, + { + "appearance": "subtle", + "contentStyle": { + "color": "#bc2f32", + "flexShrink": 1, + "fontFamily": "Helvetica Neue", + "fontSize": 14, + "fontWeight": "400", + "lineHeight": 20, + "textAlign": "center", + }, + "rootStyle": { + "alignItems": "center", + "alignSelf": "flex-start", + "backgroundColor": "#00000000", + "borderColor": "#00000000", + "borderRadius": 4, + "borderStyle": "solid", + "borderWidth": 1, + "flexDirection": "row", + "gap": 4, + "justifyContent": "center", + "minHeight": 24, + "minWidth": 24, + "paddingHorizontal": 10, + "paddingVertical": 6, + }, + }, +] +`; + +exports[`DestructiveButton matches the hovered visual state snapshot across appearances 1`] = ` +[ + { + "appearance": "primary", + "contentStyle": { + "color": "#ffffff", + "flexShrink": 1, + "fontFamily": "Helvetica Neue", + "fontSize": 14, + "fontWeight": "400", + "lineHeight": 20, + "textAlign": "center", + }, + "rootStyle": { + "alignItems": "center", + "alignSelf": "flex-start", + "backgroundColor": "#d13438", + "borderColor": "#00000000", + "borderRadius": 4, + "borderStyle": "solid", + "borderWidth": 1, + "flexDirection": "row", + "gap": 4, + "justifyContent": "center", + "minHeight": 24, + "minWidth": 24, + "paddingHorizontal": 10, + "paddingVertical": 6, + }, + }, + { + "appearance": "subtle", + "contentStyle": { + "color": "#bc2f32", + "flexShrink": 1, + "fontFamily": "Helvetica Neue", + "fontSize": 14, + "fontWeight": "400", + "lineHeight": 20, + "textAlign": "center", + }, + "rootStyle": { + "alignItems": "center", + "alignSelf": "flex-start", + "backgroundColor": "#fdf6f6", + "borderColor": "#00000000", + "borderRadius": 4, + "borderStyle": "solid", + "borderWidth": 1, + "flexDirection": "row", + "gap": 4, + "justifyContent": "center", + "minHeight": 24, + "minWidth": 24, + "paddingHorizontal": 10, + "paddingVertical": 6, + }, + }, +] +`; + +exports[`DestructiveButton matches the pressed visual state snapshot across appearances 1`] = ` +[ + { + "appearance": "primary", + "contentStyle": { + "color": "#ffffff", + "flexShrink": 1, + "fontFamily": "Helvetica Neue", + "fontSize": 14, + "fontWeight": "400", + "lineHeight": 20, + "textAlign": "center", + }, + "rootStyle": { + "alignItems": "center", + "alignSelf": "flex-start", + "backgroundColor": "#d13438", + "borderColor": "#00000000", + "borderRadius": 4, + "borderStyle": "solid", + "borderWidth": 1, + "flexDirection": "row", + "gap": 4, + "justifyContent": "center", + "minHeight": 24, + "minWidth": 24, + "paddingHorizontal": 10, + "paddingVertical": 6, + }, + }, + { + "appearance": "subtle", + "contentStyle": { + "color": "#bc2f32", + "flexShrink": 1, + "fontFamily": "Helvetica Neue", + "fontSize": 14, + "fontWeight": "400", + "lineHeight": 20, + "textAlign": "center", + }, + "rootStyle": { + "alignItems": "center", + "alignSelf": "flex-start", + "backgroundColor": "#fdf6f6", + "borderColor": "#00000000", + "borderRadius": 4, + "borderStyle": "solid", + "borderWidth": 1, + "flexDirection": "row", + "gap": 4, + "justifyContent": "center", + "minHeight": 24, + "minWidth": 24, + "paddingHorizontal": 10, + "paddingVertical": 6, + }, + }, +] +`; + +exports[`DestructiveButton matches the rest visual state snapshot across appearances 1`] = ` +[ + { + "appearance": "primary", + "contentStyle": { + "color": "#ffffff", + "flexShrink": 1, + "fontFamily": "Helvetica Neue", + "fontSize": 14, + "fontWeight": "400", + "lineHeight": 20, + "textAlign": "center", + }, + "rootStyle": { + "alignItems": "center", + "alignSelf": "flex-start", + "backgroundColor": "#d13438", + "borderColor": "#00000000", + "borderRadius": 4, + "borderStyle": "solid", + "borderWidth": 1, + "flexDirection": "row", + "gap": 4, + "justifyContent": "center", + "minHeight": 24, + "minWidth": 24, + "paddingHorizontal": 10, + "paddingVertical": 6, + }, + }, + { + "appearance": "subtle", + "contentStyle": { + "color": "#bc2f32", + "flexShrink": 1, + "fontFamily": "Helvetica Neue", + "fontSize": 14, + "fontWeight": "400", + "lineHeight": 20, + "textAlign": "center", + }, + "rootStyle": { + "alignItems": "center", + "alignSelf": "flex-start", + "backgroundColor": "#00000000", + "borderColor": "#00000000", + "borderRadius": 4, + "borderStyle": "solid", + "borderWidth": 1, + "flexDirection": "row", + "gap": 4, + "justifyContent": "center", + "minHeight": 24, + "minWidth": 24, + "paddingHorizontal": 10, + "paddingVertical": 6, + }, + }, +] +`; diff --git a/packages/agentic/components/src/components/destructive-button/destructive-button.stories.tsx b/packages/agentic/components/src/components/destructive-button/destructive-button.stories.tsx new file mode 100644 index 0000000000..4556e4f762 --- /dev/null +++ b/packages/agentic/components/src/components/destructive-button/destructive-button.stories.tsx @@ -0,0 +1,312 @@ +/** @jsxImportSource @fluentui-react-native/framework-base */ +import type { ReactNode } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +import type { Meta, StoryObj } from '@storybook/react-native'; +import type { DesktopStoryTests } from '@fluentui-react-native/desktop-driver/authoring'; + +import { DestructiveButton } from './destructive-button'; +import type { DestructiveButtonAppearance, DestructiveButtonShape, DestructiveButtonSize } from './destructive-button.types'; + +type StoryGroupProps = { + children: ReactNode; + label: string; +}; + +const StoryGroup = ({ children, label }: StoryGroupProps) => ( + + {label} + {children} + +); + +const appearances: readonly { label: string; value: DestructiveButtonAppearance }[] = [ + { label: 'Primary', value: 'primary' }, + { label: 'Subtle', value: 'subtle' }, +]; + +const sizes: readonly { label: string; value: DestructiveButtonSize }[] = [ + { label: 'Small', value: 'small' }, + { label: 'Medium', value: 'medium' }, + { label: 'Large', value: 'large' }, +]; + +const shapes: readonly { label: string; value: DestructiveButtonShape }[] = [ + { label: 'Rounded', value: 'rounded' }, + { label: 'Circle', value: 'circle' }, +]; + +const deleteIcon = { fontSource: { codepoint: 0x2716, fontFamily: 'Arial' } } as const; + +const meta: Meta = { + title: 'Components/DestructiveButton', + component: DestructiveButton, + args: { + appearance: 'primary', + content: 'Delete', + disabled: false, + iconPosition: 'before', + shape: 'rounded', + size: 'medium', + testID: 'agentic-storybook-destructive-button', + }, + argTypes: { + appearance: { control: 'select', options: appearances.map(({ value }) => value) }, + iconPosition: { control: 'select', options: ['before', 'after'] }, + shape: { control: 'select', options: shapes.map(({ value }) => value) }, + size: { control: 'select', options: sizes.map(({ value }) => value) }, + }, + parameters: { + docs: { + description: { + component: + 'A DestructiveButton triggers an irreversible or high-consequence action such as deleting, removing, or permanently discarding content. Reserve it for the confirming action itself; use Button for the surrounding neutral actions.', + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + tags: ['desktop-e2e'], + parameters: { + desktopDriver: { + version: 1, + tests: [ + { + id: 'pointer-focus', + title: 'Responds to activation and receives focus', + requires: ['element-screenshot', 'focus'], + steps: [ + { action: 'wait', target: { testId: 'agentic-storybook-destructive-button' } }, + { expect: { state: 'role', target: { testId: 'agentic-storybook-destructive-button' }, value: 'button' } }, + { expect: { state: 'enabled', target: { testId: 'agentic-storybook-destructive-button' }, value: true } }, + { action: 'click', target: { testId: 'agentic-storybook-destructive-button' } }, + { expect: { state: 'focused', target: { testId: 'agentic-storybook-destructive-button' }, value: true } }, + { action: 'screenshot', name: 'destructive-button-focused', target: { testId: 'agentic-storybook-destructive-button' } }, + ], + }, + ], + } satisfies DesktopStoryTests, + }, +}; + +export const Overview: Story = { + render: () => ( + + + {appearances.map(({ label, value }) => ( + + ))} + + + {sizes.map(({ label, value }) => ( + + ))} + + + + + + + + + + + + ), + parameters: { + docs: { + description: { + story: 'A grouped scan of the main appearance, size, content, and availability variants.', + }, + }, + }, +}; + +export const Appearance: Story = { + render: () => ( + + {appearances.map(({ label, value }) => ( + + ))} + + ), + parameters: { + docs: { + description: { + story: + 'Primary is the default and carries the full danger fill for the confirming action. Subtle keeps danger foreground on a transparent backplate for destructive actions embedded in dense surfaces such as list rows.', + }, + }, + }, +}; + +export const Size: Story = { + render: () => ( + + {sizes.map(({ label, value }) => ( + + + + + + ))} + + ), + parameters: { + docs: { + description: { + story: 'DestructiveButton supports Small, Medium, and Large sizes. Medium is the default.', + }, + }, + }, +}; + +export const Shape: Story = { + render: () => ( + + {shapes.map(({ label, value }) => + value === 'rounded' ? ( + + ) : ( + + ), + )} + + ), + parameters: { + docs: { + description: { + story: + 'Text buttons are rounded by default and icon-only buttons are circular by default. DestructiveButton has no square shape, so a destructive action can never be mistaken for a neutral square Button.', + }, + }, + }, +}; + +export const Icon: Story = { + render: () => ( + + + + + + ), + parameters: { + docs: { + description: { + story: + 'The icon slot can appear before or after content. An icon-only destructive button requires an accessibilityLabel that names the consequence, not just the glyph, and a visible tooltip in product UI.', + }, + }, + }, +}; + +export const Disabled: Story = { + render: () => ( + + {appearances.map(({ label, value }) => ( + + + + + ))} + + ), + parameters: { + docs: { + description: { + story: + 'A disabled destructive button drops the danger palette entirely so an unavailable action never reads as an armed one. It exposes disabled accessibility state and does not receive focus.', + }, + }, + }, +}; + +export const InConfirmationDialog: Story = { + render: () => ( + + Delete 3 files? + These files will be permanently removed. This cannot be undone. + + + + + + ), + parameters: { + docs: { + description: { + story: + 'The canonical usage: a confirmation surface names the consequence, and exactly one destructive action confirms it. In product UI the cancel action is a neutral Button; it appears here as a subtle DestructiveButton only to keep this story to a single component.', + }, + }, + }, +}; + +export const WithLongText: Story = { + render: () => ( + + + + + ), + parameters: { + docs: { + description: { + story: 'DestructiveButton content wraps when the root is constrained by its surrounding layout.', + }, + }, + }, +}; + +const styles = StyleSheet.create({ + dialog: { + alignItems: 'flex-start', + gap: 8, + maxWidth: 360, + }, + dialogActions: { + alignItems: 'center', + flexDirection: 'row', + gap: 8, + paddingTop: 8, + }, + dialogBody: { + fontSize: 14, + }, + dialogTitle: { + fontSize: 18, + fontWeight: '600', + }, + group: { + alignItems: 'flex-start', + gap: 8, + }, + label: { + fontSize: 12, + fontWeight: '600', + }, + longButton: { + width: 280, + }, + row: { + alignItems: 'center', + flexDirection: 'row', + flexWrap: 'wrap', + gap: 12, + }, + story: { + alignItems: 'flex-start', + gap: 16, + }, +}); diff --git a/packages/agentic/components/src/components/destructive-button/destructive-button.styles.ts b/packages/agentic/components/src/components/destructive-button/destructive-button.styles.ts new file mode 100644 index 0000000000..2faf6a0e6c --- /dev/null +++ b/packages/agentic/components/src/components/destructive-button/destructive-button.styles.ts @@ -0,0 +1,240 @@ +import { StyleSheet } from 'react-native'; +import type { TextStyle, ViewStyle } from 'react-native'; + +import type { FlexTokens } from '@fluentui-react-native/design'; +import { + getGapStyleValue, + getThemedColorStyleFactory, + getThemedStateStyleFactory, + interactiveStatePriority, +} from '@fluentui-react-native/design/styling'; +import type { + ColorStyleDefinition, + StateNames, + StyleDefinition, + TextColorStyle, + ViewColorStyle, +} from '@fluentui-react-native/design/styling'; +import { size240 } from '@fluentui-react-native/design/tokens/global'; + +import { getButtonIconSize } from '../button/button.styles'; + +import type { DestructiveButtonState } from './destructive-button.types'; + +export const destructiveButtonStyles = StyleSheet.create({ + root: { + alignItems: 'center', + alignSelf: 'flex-start', + borderStyle: 'solid', + flexDirection: 'row', + justifyContent: 'center', + }, + content: { + flexShrink: 1, + textAlign: 'center', + }, +}); + +const colorStateLevels = [['primary', 'subtle'], interactiveStatePriority] as const; +type ColorStateLevels = typeof colorStateLevels; +type ColorState = StateNames; + +const backgroundColorDefinition: ColorStyleDefinition = { + primary: { + backgroundColor: 'backgroundDangerLoud', + borderColor: 'strokeNeutralTransparent', + disabled: { + backgroundColor: 'backgroundNeutralHeavyDisabled', + }, + }, + subtle: { + backgroundColor: 'backgroundNeutralTransparent', + borderColor: 'strokeNeutralTransparent', + // The rest background is transparent, so hover and press resolve from the danger tint instead of + // the transparent rest value, which would otherwise produce a neutral backplate. + hovered: { + backgroundColor: 'backgroundDangerSubtle', + }, + pressed: { + backgroundColor: 'backgroundDangerSubtle', + }, + }, +}; + +const foregroundColorDefinition: ColorStyleDefinition = { + primary: { + color: 'foregroundDangerOnloud', + disabled: { + color: 'foregroundNeutralDisabled', + }, + }, + subtle: { + color: 'foregroundDangerPrimary', + disabled: { + color: 'foregroundNeutralDisabled', + }, + }, +}; + +const getThemedBackgroundStyle = getThemedColorStyleFactory( + 'DestructiveButton.background', + backgroundColorDefinition, + colorStateLevels, +); +const getThemedForegroundStyle = getThemedColorStyleFactory( + 'DestructiveButton.foreground', + foregroundColorDefinition, + colorStateLevels, +); + +function getColorStateSource(state: DestructiveButtonState): ColorState[] { + const source: ColorState[] = [state.appearance]; + if (state.disabled) { + source.push('disabled'); + } + if (state.pressed) { + source.push('pressed'); + } + if (state.hovered) { + source.push('hovered'); + } + return source; +} + +export function getDestructiveButtonColorStyles(state: DestructiveButtonState): { + background: ViewColorStyle; + foreground: TextColorStyle; +} { + const source = getColorStateSource(state); + return { + background: getThemedBackgroundStyle(state, source), + foreground: getThemedForegroundStyle(state, source), + }; +} + +const rootStyleStateLevels = [ + ['small', 'medium', 'large'], + ['rounded', 'circle'], + ['withContent', 'iconOnly'], +] as const; +type RootStyleStateLevels = typeof rootStyleStateLevels; +type RootStyleState = StateNames; + +function createSizeStyle( + roundedRadius: NonNullable, + circleRadius: NonNullable, + withContent: ViewStyle, + iconOnly: ViewStyle, +) { + return { + rounded: { + borderRadius: roundedRadius, + iconOnly, + withContent, + }, + circle: { + borderRadius: circleRadius, + iconOnly, + withContent, + }, + }; +} + +function createRootStyleDefinition({ borderRadius, spacing, strokeWidth }: FlexTokens): StyleDefinition { + return { + borderWidth: strokeWidth.thin, + minHeight: size240, + minWidth: size240, + small: createSizeStyle( + borderRadius.base200, + borderRadius.circular, + { + gap: getGapStyleValue(spacing.componentBase50), + paddingHorizontal: spacing.componentBase200, + paddingVertical: spacing.componentBase100, + }, + { + paddingHorizontal: spacing.componentBase100, + paddingVertical: spacing.componentBase100, + }, + ), + medium: createSizeStyle( + borderRadius.base300, + borderRadius.circular, + { + gap: getGapStyleValue(spacing.componentBase100), + paddingHorizontal: spacing.componentBase250, + paddingVertical: spacing.componentBase150, + }, + { + paddingHorizontal: spacing.componentBase150, + paddingVertical: spacing.componentBase150, + }, + ), + large: createSizeStyle( + borderRadius.base400, + borderRadius.circular, + { + gap: getGapStyleValue(spacing.componentBase150), + paddingHorizontal: spacing.componentBase300, + paddingVertical: spacing.componentBase200, + }, + { + paddingHorizontal: spacing.componentBase250, + paddingVertical: spacing.componentBase250, + }, + ), + }; +} + +const getThemedRootStyle = getThemedStateStyleFactory('DestructiveButton.root', createRootStyleDefinition, rootStyleStateLevels); + +function getRootStyleStateSource(state: DestructiveButtonState): RootStyleState[] { + return [state.size, state.shape, state.iconOnly ? 'iconOnly' : 'withContent']; +} + +export function getDestructiveButtonRootStyle(state: DestructiveButtonState): ViewStyle { + return getThemedRootStyle(state, getRootStyleStateSource(state)); +} + +const contentStyleStateLevels = [['small', 'medium', 'large']] as const; +type ContentStyleStateLevels = typeof contentStyleStateLevels; + +function createContentStyleDefinition({ + fontFamily, + fontSize, + fontWeight, + lineHeight, +}: FlexTokens): StyleDefinition { + return { + fontFamily: fontFamily.functional, + fontWeight: fontWeight.functionalRegular, + small: { + fontSize: fontSize.functionalBodySmall, + lineHeight: lineHeight.functionalBodySmall, + }, + medium: { + fontSize: fontSize.functionalBodyMedium, + lineHeight: lineHeight.functionalBodyMedium, + }, + large: { + fontSize: fontSize.functionalBodyLarge, + lineHeight: lineHeight.functionalBodyLarge, + }, + }; +} + +const getThemedContentStyle = getThemedStateStyleFactory( + 'DestructiveButton.content', + createContentStyleDefinition, + contentStyleStateLevels, +); + +export function getDestructiveButtonContentStyle(state: DestructiveButtonState): TextStyle { + return getThemedContentStyle(state, [state.size]); +} + +/** + * Icon sizing is inherited from Button so the button family cannot drift apart. + */ +export const getDestructiveButtonIconSize = getButtonIconSize; diff --git a/packages/agentic/components/src/components/destructive-button/destructive-button.test.tsx b/packages/agentic/components/src/components/destructive-button/destructive-button.test.tsx new file mode 100644 index 0000000000..c6d50e1aea --- /dev/null +++ b/packages/agentic/components/src/components/destructive-button/destructive-button.test.tsx @@ -0,0 +1,305 @@ +/** @jsxImportSource @fluentui-react-native/framework-base */ +import * as React from 'react'; +import { StyleSheet, View } from 'react-native'; +import type { Pressable, PressableProps, ViewStyle } from 'react-native'; + +import { fireEvent, render } from '@testing-library/react-native'; +import type { RenderResult } from '@testing-library/react-native'; + +import { defaultFlexTokens } from '@fluentui-react-native/design/testing'; + +import { DestructiveButton } from './destructive-button'; +import type { DestructiveButtonAppearance } from './destructive-button.types'; + +function renderDestructiveButton(props: React.ComponentProps): Promise { + return render(); +} + +function getRoot(component: RenderResult) { + return component.getByRole('button'); +} + +function getRootStyle(component: RenderResult): ViewStyle { + return StyleSheet.flatten(getRoot(component).props.style); +} + +const colors = defaultFlexTokens.color; + +describe('DestructiveButton', () => { + it('forwards its ref prop to the native root', async () => { + const ref = React.createRef>(); + + await renderDestructiveButton({ content: 'Delete', ref }); + + expect(ref.current).not.toBeNull(); + }); + + it('reuses cached theme styles without recreating them for another instance', async () => { + const createStyleSheet = jest.spyOn(StyleSheet, 'create'); + + await renderDestructiveButton({ content: 'First' }); + const createCount = createStyleSheet.mock.calls.length; + await renderDestructiveButton({ content: 'Second' }); + + expect(createStyleSheet).toHaveBeenCalledTimes(createCount); + createStyleSheet.mockRestore(); + }); + + it('renders content with default button accessibility and primary danger styling', async () => { + const component = await renderDestructiveButton({ content: 'Delete' }); + const root = getRoot(component); + + expect(root.props.role).toBe('button'); + expect(root.props.accessibilityState).toEqual({ disabled: false }); + expect(root.props.focusable).toBe(true); + expect(component.getByText('Delete')).toBeOnTheScreen(); + expect(getRootStyle(component)).toMatchObject({ + alignItems: 'center', + backgroundColor: colors.backgroundDangerLoud, + borderRadius: 4, + minHeight: 24, + minWidth: 24, + }); + expect(StyleSheet.flatten(component.getByText('Delete').props.style).color).toBe(colors.foregroundDangerOnloud); + }); + + it('forwards press and interaction handlers', async () => { + const onHoverIn = jest.fn(); + const onPress = jest.fn(); + const component = await renderDestructiveButton({ content: 'Delete', onHoverIn, onPress }); + const root = getRoot(component); + + await fireEvent(root, 'hoverIn', {}); + expect(onHoverIn).toHaveBeenCalledTimes(1); + + await fireEvent.press(root); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('disables interaction and exposes disabled accessibility state', async () => { + const onPress = jest.fn(); + const component = await renderDestructiveButton({ content: 'Unavailable', disabled: true, onPress }); + const root = getRoot(component); + + expect(root).toBeDisabled(); + expect(root.props.focusable).toBe(false); + expect(root.props.accessibilityState).toEqual({ disabled: true }); + expect(getRootStyle(component).backgroundColor).toBe(colors.backgroundNeutralHeavyDisabled); + expect(StyleSheet.flatten(component.getByText('Unavailable').props.style).color).toBe(colors.foregroundNeutralDisabled); + await fireEvent.press(root); + expect(onPress).not.toHaveBeenCalled(); + }); + + it('renders an accessible icon-only button at the minimum target size', async () => { + const component = await renderDestructiveButton({ + accessibilityLabel: 'Delete item', + icon: { imageSource: { uri: 'delete.png' }, testID: 'delete-icon' }, + size: 'small', + }); + const root = getRoot(component); + const image = component.getByTestId('delete-icon'); + + expect(root.props.accessibilityLabel).toBe('Delete item'); + expect(getRootStyle(component)).toMatchObject({ + borderRadius: 9999, + minHeight: 24, + minWidth: 24, + paddingHorizontal: 4, + paddingVertical: 4, + }); + expect(image.props.style).toMatchObject({ height: 16, width: 16 }); + expect(image.props.accessible).toBe(false); + }); + + it('warns when an icon-only button has no accessible name', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(); + await renderDestructiveButton({ icon: { imageSource: { uri: 'delete.png' } } }); + + expect(warn).toHaveBeenCalledWith('DestructiveButton: icon-only buttons require an accessibilityLabel that describes the action.'); + warn.mockRestore(); + }); + + it('never reports selection state and does not gain it on press', async () => { + const onPress = jest.fn(); + const component = await renderDestructiveButton({ content: 'Delete', onPress }); + const root = getRoot(component); + + expect(root.props.accessibilityState).toEqual({ disabled: false }); + + await fireEvent.press(root); + + expect(onPress).toHaveBeenCalledTimes(1); + expect(getRoot(component).props.accessibilityState).toEqual({ disabled: false }); + expect(component.getAllByText('Delete', { includeHiddenElements: true })).toHaveLength(1); + }); + + it('places the icon after content and applies user styles last', async () => { + const style: ViewStyle = { backgroundColor: 'hotpink' }; + const component = await renderDestructiveButton({ + content: { children: 'Delete', testID: 'content' }, + icon: { imageSource: { uri: 'delete.png' }, testID: 'icon' }, + iconPosition: 'after', + style, + }); + const root = getRoot(component); + const content = component.getByTestId('content'); + const icon = component.getByTestId('icon'); + + expect(root.children.slice(1)).toEqual([content, icon]); + expect(getRootStyle(component).backgroundColor).toBe('hotpink'); + }); + + it('renders a persistent dual-ring focus visual', async () => { + const component = await renderDestructiveButton({ content: 'Delete' }); + const root = getRoot(component); + const focusVisual = () => component.getByTestId('focus-visual', { includeHiddenElements: true }); + + expect(root.props.enableFocusRing).toBe(false); + expect(StyleSheet.flatten(focusVisual().props.style).opacity).toBe(0); + + await fireEvent(root, 'focus', {}); + + expect(StyleSheet.flatten(focusVisual().props.style)).toMatchObject({ + borderColor: colors.strokeFocusOuter, + borderWidth: defaultFlexTokens.strokeWidth.thick, + }); + expect(StyleSheet.flatten(focusVisual().props.style)).not.toHaveProperty('opacity'); + expect(StyleSheet.flatten(component.getByTestId('focus-visual-inner', { includeHiddenElements: true }).props.style)).toMatchObject({ + borderColor: colors.strokeFocusInner, + borderWidth: defaultFlexTokens.strokeWidth.thin, + }); + }); + + it('hides the focus visual while disabled', async () => { + const component = await renderDestructiveButton({ content: 'Delete', disabled: true }); + + await fireEvent(getRoot(component), 'focus', {}); + + expect(StyleSheet.flatten(component.getByTestId('focus-visual', { includeHiddenElements: true }).props.style).opacity).toBe(0); + }); + + it.each([ + ['primary', 'backgroundDangerLoud', 'foregroundDangerOnloud'], + ['subtle', 'backgroundNeutralTransparent', 'foregroundDangerPrimary'], + ] as const)('resolves the %s appearance from the danger token family', async (appearance, background, foreground) => { + const component = await renderDestructiveButton({ appearance, content: appearance }); + + expect(getRootStyle(component)).toMatchObject({ + backgroundColor: colors[background], + // Neither appearance draws a stroke; DestructiveButton has no outline emphasis level. + borderColor: colors.strokeNeutralTransparent, + }); + expect(StyleSheet.flatten(component.getByText(appearance).props.style).color).toBe(colors[foreground]); + }); + + it('reveals a danger tint when the subtle appearance is hovered or pressed', async () => { + const component = await renderDestructiveButton({ appearance: 'subtle', content: 'Remove' }); + const root = getRoot(component); + + expect(getRootStyle(component).backgroundColor).toBe(colors.backgroundNeutralTransparent); + + await fireEvent(root, 'hoverIn', {}); + expect(getRootStyle(component).backgroundColor).toBe(colors.hover.backgroundDangerSubtle); + + await fireEvent(root, 'pressIn', {}); + expect(getRootStyle(component).backgroundColor).toBe(colors.pressed.backgroundDangerSubtle); + }); + + it('resolves the danger loud interaction backgrounds the theme currently supplies', async () => { + const component = await renderDestructiveButton({ appearance: 'primary', content: 'Delete' }); + const root = getRoot(component); + + await fireEvent(root, 'hoverIn', {}); + expect(getRootStyle(component).backgroundColor).toBe(colors.hover.backgroundDangerLoud); + + await fireEvent(root, 'pressIn', {}); + expect(getRootStyle(component).backgroundColor).toBe(colors.pressed.backgroundDangerLoud); + }); + + it('keeps disabled above pressed and hovered in state precedence', async () => { + const component = await renderDestructiveButton({ content: 'Delete', disabled: true }); + const root = getRoot(component); + + await fireEvent(root, 'hoverIn', {}); + await fireEvent(root, 'pressIn', {}); + + expect(getRootStyle(component).backgroundColor).toBe(colors.backgroundNeutralHeavyDisabled); + }); + + it('allows constrained content to wrap', async () => { + const component = await renderDestructiveButton({ + content: { children: 'Delete every selected item', testID: 'content' }, + style: { width: 120 }, + }); + const content = component.getByTestId('content'); + + expect(content.props.numberOfLines).toBeUndefined(); + expect(StyleSheet.flatten(content.props.style)).toMatchObject({ flexShrink: 1 }); + }); + + it.each([ + ['small', 12, 8, 4, 16], + ['medium', 14, 10, 4, 20], + ['large', 16, 12, 6, 20], + ] as const)('resolves the %s size', async (size, fontSize, paddingHorizontal, borderRadius, iconSize) => { + const component = await renderDestructiveButton({ + content: size, + icon: { imageSource: { uri: 'delete.png' }, testID: 'icon' }, + size, + }); + + expect(StyleSheet.flatten(component.getByText(size).props.style)).toMatchObject({ + fontFamily: expect.any(String), + fontSize, + fontWeight: defaultFlexTokens.fontWeight.functionalRegular, + }); + expect(getRootStyle(component).paddingHorizontal).toBe(paddingHorizontal); + expect(getRootStyle(component).borderRadius).toBe(borderRadius); + expect(component.getByTestId('icon').props.style).toMatchObject({ height: iconSize, width: iconSize }); + }); + + it.each([ + ['rounded', 4], + ['circle', 9999], + ] as const)('applies an explicit %s shape', async (shape, borderRadius) => { + const component = await renderDestructiveButton({ content: shape, shape }); + expect(getRootStyle(component).borderRadius).toBe(borderRadius); + }); + + it('preserves user accessibility state values', async () => { + const props: Pick = { + accessibilityState: { busy: true }, + }; + const component = await renderDestructiveButton({ content: 'Deleting', ...props }); + expect(getRoot(component).props.accessibilityState).toEqual({ busy: true, disabled: false }); + }); + + const appearances: DestructiveButtonAppearance[] = ['primary', 'subtle']; + const visualStates = ['rest', 'hovered', 'pressed', 'focused', 'disabled'] as const; + + it.each(visualStates)('matches the %s visual state snapshot across appearances', async (visualState) => { + const disabled = visualState === 'disabled'; + const component = await render( + + {appearances.map((appearance) => ( + + ))} + , + ); + + if (visualState === 'hovered' || visualState === 'pressed' || visualState === 'focused') { + const eventName = visualState === 'hovered' ? 'hoverIn' : visualState === 'pressed' ? 'pressIn' : 'focus'; + for (const button of component.getAllByRole('button')) { + await fireEvent(button, eventName, {}); + } + } + + const visualSnapshot = component.getAllByRole('button').map((button, index) => ({ + appearance: appearances[index], + contentStyle: StyleSheet.flatten(component.getByText(appearances[index]).props.style), + rootStyle: StyleSheet.flatten(button.props.style), + })); + + expect(visualSnapshot).toMatchSnapshot(); + }); +}); diff --git a/packages/agentic/components/src/components/destructive-button/destructive-button.ts b/packages/agentic/components/src/components/destructive-button/destructive-button.ts new file mode 100644 index 0000000000..33aa8401c4 --- /dev/null +++ b/packages/agentic/components/src/components/destructive-button/destructive-button.ts @@ -0,0 +1,17 @@ +import type { DestructiveButtonProps } from './destructive-button.types'; +import { useDestructiveButton_unstable } from './useDestructiveButton'; +import { useDestructiveButtonStyles_unstable } from './useDestructiveButtonStyles'; +import { renderDestructiveButton_unstable } from './renderDestructiveButton'; + +/** + * A DestructiveButton component, which triggers a single irreversible or high-consequence action and + * carries the danger color family so the control itself signals the outcome. + */ +export const DestructiveButton = (props: DestructiveButtonProps) => { + const state = useDestructiveButton_unstable(props); + useDestructiveButtonStyles_unstable(state); + return renderDestructiveButton_unstable(state); +}; +DestructiveButton.displayName = 'DestructiveButton'; + +export default DestructiveButton; diff --git a/packages/agentic/components/src/components/destructive-button/destructive-button.types.test.tsx b/packages/agentic/components/src/components/destructive-button/destructive-button.types.test.tsx new file mode 100644 index 0000000000..c62e8aceb2 --- /dev/null +++ b/packages/agentic/components/src/components/destructive-button/destructive-button.types.test.tsx @@ -0,0 +1,46 @@ +/** @jsxImportSource @fluentui-react-native/framework-base */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { DestructiveButton } from './destructive-button'; + +const PrimaryDestructiveButton = undefined} />; + +const FullyConfiguredDestructiveButton = ( + +); + +// @ts-expect-error DestructiveButton has no selection axis; toggling belongs to Button. +const InvalidSelected = ; + +// @ts-expect-error DestructiveButton has no selection axis, so it has no selected icon slot either. +const InvalidSelectedIcon = ; + +// @ts-expect-error DestructiveButton omits Button's square shape. +const InvalidShape = ; + +// @ts-expect-error the danger emphasis axis has only primary and subtle. +const InvalidAppearance = ; + +// @ts-expect-error the danger emphasis axis has no outline level. +const InvalidOutlineAppearance = ; + +// @ts-expect-error size accepts only the three supported ramp values. +const InvalidSize = ; + +// @ts-expect-error iconPosition accepts only before and after. +const InvalidIconPosition = ; + +describe('DestructiveButton types', () => { + it('accepts the reviewed destructive button prop surface', () => { + expect(PrimaryDestructiveButton).toBeDefined(); + expect(FullyConfiguredDestructiveButton).toBeDefined(); + }); +}); diff --git a/packages/agentic/components/src/components/destructive-button/destructive-button.types.ts b/packages/agentic/components/src/components/destructive-button/destructive-button.types.ts new file mode 100644 index 0000000000..5ca0cf9c90 --- /dev/null +++ b/packages/agentic/components/src/components/destructive-button/destructive-button.types.ts @@ -0,0 +1,93 @@ +import type { Pressable, StyleProp, ViewStyle } from 'react-native'; +import type { + Slot, + OptionalSlot, + OwnedRootProps, + ComponentProps, + ComponentState, + PressableState, + PropsWithRefOf, +} from '@fluentui-react-native/framework-base'; +import type { ThemeState } from '@fluentui-react-native/design'; +import type { Icon } from '../../primitives/icon/icon'; +import type { FocusVisualProps } from '../../primitives/focus-visual/focus-visual.types'; +import type { Text } from '../text/text'; + +export type DestructiveButtonSlots = { + /** + * The main container of the destructive button. + */ + root: Slot; + + /** + * The label of the destructive button. This slot is optional and requires text to be set in + * DestructiveButtonProps['content'] for the slot to appear, either by content={"Delete"} + * or content={{ children: "Delete" }} + */ + content: OptionalSlot; + + /** + * The icon displayed within the destructive button. This slot is optional and requires an icon + * to be set in DestructiveButtonProps['icon'] for the slot to appear. + */ + icon: OptionalSlot; +}; + +export type DestructiveButtonAppearance = 'primary' | 'subtle'; +export type DestructiveButtonSize = 'small' | 'medium' | 'large'; +export type DestructiveButtonShape = 'rounded' | 'circle'; +export type DestructiveButtonIconPosition = 'before' | 'after'; + +export type DestructiveButtonStateProps = { + /** + * Whether the destructive button is disabled. + */ + disabled?: boolean; + /** + * The danger emphasis level of the destructive button. + */ + appearance?: DestructiveButtonAppearance; + /** + * The size of the destructive button. + */ + size?: DestructiveButtonSize; + /** + * The shape of the destructive button. + */ + shape?: DestructiveButtonShape; + /** + * The position of the icon relative to the content. + */ + iconPosition?: DestructiveButtonIconPosition; +}; + +/** + * Props that are exposed from the underlying Pressable component at the top level. A destructive button + * controls its own children and resolves styles from tokens, so those props are exposed separately. + */ +export type DestructiveButtonExposedPressableProps = OwnedRootProps>; + +/** + * Props for the DestructiveButton component, including state props, slot props, and exposed Pressable props. + */ +export type DestructiveButtonProps = DestructiveButtonStateProps & + ComponentProps; + +/** + * The destructive button state, returned from the useDestructiveButton hook. + */ +export type DestructiveButtonState = ComponentState & + Required & + Omit & + PressableState & { + focusVisualProps?: FocusVisualProps; + /** + * Whether the destructive button is displaying only an icon without content. This is set automatically + * when the button has an icon and no content. + */ + iconOnly: boolean; + /** + * User styling applied after the component's token-derived root styles. + */ + userStyle?: StyleProp; + }; diff --git a/packages/agentic/components/src/components/destructive-button/renderDestructiveButton.tsx b/packages/agentic/components/src/components/destructive-button/renderDestructiveButton.tsx new file mode 100644 index 0000000000..cfde47e4bc --- /dev/null +++ b/packages/agentic/components/src/components/destructive-button/renderDestructiveButton.tsx @@ -0,0 +1,21 @@ +/** @jsxImportSource @fluentui-react-native/framework-base */ +import type { DestructiveButtonState } from './destructive-button.types'; +import { FocusVisual } from '../../primitives/focus-visual/focus-visual'; + +/** + * Render the DestructiveButton component + * @param state The state of the DestructiveButton component containing slots and other state information. + * @returns The rendered DestructiveButton component. + */ +export function renderDestructiveButton_unstable(state: DestructiveButtonState) { + const { content: Content, icon: ActiveIcon, iconPosition } = state; + + return ( + + + {iconPosition === 'before' && ActiveIcon && } + {Content && } + {iconPosition === 'after' && ActiveIcon && } + + ); +} diff --git a/packages/agentic/components/src/components/destructive-button/spec/accessibility.md b/packages/agentic/components/src/components/destructive-button/spec/accessibility.md new file mode 100644 index 0000000000..f69b4c9d00 --- /dev/null +++ b/packages/agentic/components/src/components/destructive-button/spec/accessibility.md @@ -0,0 +1,61 @@ +# DestructiveButton accessibility + +## Native semantics + +The root is one accessible React Native element with `role="button"`. Danger is +carried by color and by the label, never by a distinct role. +`accessibilityState.disabled` always reflects `disabled`. Other caller-provided +accessibility state, such as `busy`, is preserved. + +DestructiveButton has no selection axis, so the root never reports checked or +pressed state. A destructive command fires once and returns to rest. + +The root defaults to `accessible={true}` and is focusable unless disabled. +Callers may provide an accessible name through `accessibilityLabel` or +`accessibilityLabelledBy`, and may point at supporting text with +`accessibilityDescribedBy` when the consequence needs more context than the +label carries. + +## Naming and icon-only buttons + +The visible label must be contained in the accessible name so voice control can +target what a person reads. Keep destructive labels short and specific: a +truncated verb can hide the real consequence. + +An icon-only button must provide a concise label that describes the action, +such as "Delete item" rather than the icon's visual name. Development builds +warn when neither supported naming prop is present. Product UI should also +provide visible context, commonly a tooltip, for people who do not recognize +the icon. + +The icon slot is a decorative child of the named root and sets +`accessible={false}`. Do not give it a second announced label. + +## Contrast and target size + +Label and icon colors meet at least 4.5 to 1 against their resolved background +in every enabled state, for the primary danger fill and for the subtle +foreground over both its transparent rest background and its hovered tint. The +primary fill boundary and the focus rings meet at least 3 to 1 against adjacent +colors. Disabled colors are intentionally lower contrast but stay legible at no +less than 2 to 1. + +The root keeps a minimum 24 by 24 layout. Small icon-only buttons sit at that +boundary, so surrounding interactive elements must not encroach on them. + +## State and focus + +Disabled buttons communicate unavailable state, do not activate, and are +removed from keyboard focus. Do not use disabled to hide the reason an action +is blocked; explain it nearby instead. + +The visible focus indicator is the persistent shared `FocusVisual`, which draws +inner and outer rings from focus stroke tokens. The native Windows focus ring +is disabled to prevent a competing or unstable focus border. + +## Error prevention + +Color signals severity but does not prevent accidental activation. An action +that destroys data the person controls should be confirmed or reversible. When +activation opens a confirmation surface, that surface owns its own +announcement, initial focus, and focus return. diff --git a/packages/agentic/components/src/components/destructive-button/spec/interaction.md b/packages/agentic/components/src/components/destructive-button/spec/interaction.md new file mode 100644 index 0000000000..d9acd34bc4 --- /dev/null +++ b/packages/agentic/components/src/components/destructive-button/spec/interaction.md @@ -0,0 +1,52 @@ +# DestructiveButton interaction + +## State model + +`usePressableState` derives hover, press, and focus from the root `Pressable`. +Token resolution applies appearance first, then interaction state. Disabled +values override interactive presentation, pressed overrides hovered, and the +user root style is the final style layer. + +The component forwards native action and interaction handlers. It does not trap +focus, implement arrow-key navigation, or move focus after activation. + +## Activation + +Native button behavior handles keyboard and pointer activation on Windows and +macOS. A disabled button neither focuses nor invokes its action. +DestructiveButton is a single focusable element rather than part of a composite +widget, so it adds no arrow-key model. + +Activation fires the caller's action once. The component has no selection axis +and holds no state across activations, so there is no controlled or +uncontrolled value to reconcile and no label-width reservation to keep the +layout stable. + +When activation opens a confirmation surface, that surface owns initial focus, +focus containment, and focus return. + +## Danger feedback + +The primary appearance moves within the danger loud family across rest, +hovered, and pressed. The subtle appearance is transparent at rest and reveals +a danger tint on hover and press, resolved from the danger subtle token rather +than from its transparent rest value, so the interaction still reads as +dangerous rather than neutral. + +The danger loud interaction values have no FURN theme mapping today, so the +primary appearance currently resolves one background across rest, hover, and +press. That is a recorded token gap rather than a contract decision; the +component already asks for the interaction values and will pick them up when +the theme supplies them. See `spec/tokens.yaml`. + +Neither appearance draws a stroke in any state. + +## Focus and motion + +The focus visual stays in the tree for the lifetime of the button. Focus +changes its visibility rather than adding or removing border-bearing native +views. It is hidden while disabled. + +DestructiveButton performs no timed state animation. Appearance, interaction, +and focus styles update immediately, so reduced-motion handling adds no +separate branch. diff --git a/packages/agentic/components/src/components/destructive-button/spec/source.json b/packages/agentic/components/src/components/destructive-button/spec/source.json new file mode 100644 index 0000000000..09b690fb05 --- /dev/null +++ b/packages/agentic/components/src/components/destructive-button/spec/source.json @@ -0,0 +1,172 @@ +{ + "schemaVersion": 2, + "component": "destructive-button", + "lifecycle": "implemented", + "conformance": "reviewed", + "reviewedAt": "2026-09-02", + "sources": [ + { + "id": "flex-component", + "kind": "flex-skill", + "authority": "normative", + "skill": "flex-components:destructive-button", + "sourceLock": "flex-1.5.0-206c4996", + "sourceLockFingerprint": "a69997212ec1b89510c94176801bf5a146ed7e7d8c80cc7db40ac8f60cf9f119", + "availableSurfaces": ["mobile", "shared", "web"], + "surfacesConsulted": ["mobile", "shared", "web"], + "sourceFiles": [ + { + "role": "mobile:android:accessibility", + "marketplacePath": "catalogs/flex/plugins/components/skills/destructive-button/mobile/android/accessibility.md", + "marketplaceBlobSha": "1603e91b5870db1ee4c2b597f38bfcf9b0e5b319", + "marketplaceSha256": "9871d49d7197cf40f29c8c09a35ef33d494516bde6464b2c441cc54214af6ef8", + "originPath": "plugins/components/skills/destructive-button/mobile/android/accessibility.md", + "originBlobSha": "1603e91b5870db1ee4c2b597f38bfcf9b0e5b319", + "originSha256": "9871d49d7197cf40f29c8c09a35ef33d494516bde6464b2c441cc54214af6ef8", + "contentDiffers": false + }, + { + "role": "mobile:android:interaction", + "marketplacePath": "catalogs/flex/plugins/components/skills/destructive-button/mobile/android/interaction.md", + "marketplaceBlobSha": "6f04852df1314adf3cff7e7acadb7e4b1a4e0545", + "marketplaceSha256": "76f50ad60ffdcd1e891323e78da932dc246ebfeb07b05fa8dbfa31ce96200c93", + "originPath": "plugins/components/skills/destructive-button/mobile/android/interaction.md", + "originBlobSha": "6f04852df1314adf3cff7e7acadb7e4b1a4e0545", + "originSha256": "76f50ad60ffdcd1e891323e78da932dc246ebfeb07b05fa8dbfa31ce96200c93", + "contentDiffers": false + }, + { + "role": "mobile:ios:accessibility", + "marketplacePath": "catalogs/flex/plugins/components/skills/destructive-button/mobile/ios/accessibility.md", + "marketplaceBlobSha": "0526b4a40a528e10e652d12795ad8a2723da8cec", + "marketplaceSha256": "25db466cc31f66184cbc43d2cff1821f6d6edd4fc8b747074a4971f74cff2f0f", + "originPath": "plugins/components/skills/destructive-button/mobile/ios/accessibility.md", + "originBlobSha": "0526b4a40a528e10e652d12795ad8a2723da8cec", + "originSha256": "25db466cc31f66184cbc43d2cff1821f6d6edd4fc8b747074a4971f74cff2f0f", + "contentDiffers": false + }, + { + "role": "mobile:ios:interaction", + "marketplacePath": "catalogs/flex/plugins/components/skills/destructive-button/mobile/ios/interaction.md", + "marketplaceBlobSha": "7769f05147cd2584e58482115b3a2258b54e9791", + "marketplaceSha256": "cacc0d5d038cd3b0a3ea982391021a55ef3883d4d9e4e22a188a118d3c1ac682", + "originPath": "plugins/components/skills/destructive-button/mobile/ios/interaction.md", + "originBlobSha": "7769f05147cd2584e58482115b3a2258b54e9791", + "originSha256": "cacc0d5d038cd3b0a3ea982391021a55ef3883d4d9e4e22a188a118d3c1ac682", + "contentDiffers": false + }, + { + "role": "mobile:overview", + "marketplacePath": "catalogs/flex/plugins/components/skills/destructive-button/mobile/overview.md", + "marketplaceBlobSha": "9432005c544aa945ebdd5129c57c9b28abf3e589", + "marketplaceSha256": "2f56463dad7ab7d16a34ecfceac5298f58e7bb35e3bafea5d792001269e1dac7", + "originPath": "plugins/components/skills/destructive-button/mobile/overview.md", + "originBlobSha": "9432005c544aa945ebdd5129c57c9b28abf3e589", + "originSha256": "2f56463dad7ab7d16a34ecfceac5298f58e7bb35e3bafea5d792001269e1dac7", + "contentDiffers": false + }, + { + "role": "mobile:tokens", + "marketplacePath": "catalogs/flex/plugins/components/skills/destructive-button/mobile/tokens.yaml", + "marketplaceBlobSha": "ba0f4b471dd730acfed5d55bd7a56cdfd728acbf", + "marketplaceSha256": "ef00990c666a5c07ad469dc551d26a804d3e10219fe4f31faf100d22da4354d5", + "originPath": "plugins/components/skills/destructive-button/mobile/tokens.yaml", + "originBlobSha": "ba0f4b471dd730acfed5d55bd7a56cdfd728acbf", + "originSha256": "ef00990c666a5c07ad469dc551d26a804d3e10219fe4f31faf100d22da4354d5", + "contentDiffers": false + }, + { + "role": "skill", + "marketplacePath": "catalogs/flex/plugins/components/skills/destructive-button/SKILL.md", + "marketplaceBlobSha": "b7862b1b563a02b5fbbb9b59543f54d8c12f1a04", + "marketplaceSha256": "89ee94f9303d93cf1191793191353a00e83bc4948c5cd4a60d250e4ea86c2427", + "originPath": "plugins/components/skills/destructive-button/SKILL.md", + "originBlobSha": "b7862b1b563a02b5fbbb9b59543f54d8c12f1a04", + "originSha256": "89ee94f9303d93cf1191793191353a00e83bc4948c5cd4a60d250e4ea86c2427", + "contentDiffers": false + }, + { + "role": "usage", + "marketplacePath": "catalogs/flex/plugins/components/skills/destructive-button/usage.md", + "marketplaceBlobSha": "02a4d1df222e6f116d08541f954aff696649dd82", + "marketplaceSha256": "fc3d79bcd9a48261611e5fa4ce7beec66147fb7d8b21df62964a69b5aaf18e0d", + "originPath": "plugins/components/skills/destructive-button/usage.md", + "originBlobSha": "02a4d1df222e6f116d08541f954aff696649dd82", + "originSha256": "fc3d79bcd9a48261611e5fa4ce7beec66147fb7d8b21df62964a69b5aaf18e0d", + "contentDiffers": false + }, + { + "role": "web:accessibility", + "marketplacePath": "catalogs/flex/plugins/components/skills/destructive-button/web/accessibility.md", + "marketplaceBlobSha": "4696491055e57faf11bf156c782e24a8fb5397c6", + "marketplaceSha256": "c0f35538e6c6f8bc35b16e89da0a8b425aa494b41aa24ceb4c3f13dd320c1896", + "originPath": "plugins/components/skills/destructive-button/web/accessibility.md", + "originBlobSha": "4696491055e57faf11bf156c782e24a8fb5397c6", + "originSha256": "c0f35538e6c6f8bc35b16e89da0a8b425aa494b41aa24ceb4c3f13dd320c1896", + "contentDiffers": false + }, + { + "role": "web:interaction", + "marketplacePath": "catalogs/flex/plugins/components/skills/destructive-button/web/interaction.md", + "marketplaceBlobSha": "65f6d828cecf291e2266827d192d1c3e07acb898", + "marketplaceSha256": "86a2cc1e76a80aba2620aad83e86191c645daa501f9c4cceef59743b4e128289", + "originPath": "plugins/components/skills/destructive-button/web/interaction.md", + "originBlobSha": "65f6d828cecf291e2266827d192d1c3e07acb898", + "originSha256": "86a2cc1e76a80aba2620aad83e86191c645daa501f9c4cceef59743b4e128289", + "contentDiffers": false + }, + { + "role": "web:tokens", + "marketplacePath": "catalogs/flex/plugins/components/skills/destructive-button/web/tokens.yaml", + "marketplaceBlobSha": "3075c21e58eeab117f9cd48db55ab8fcb5742ceb", + "marketplaceSha256": "f615d75ce795bacb007a8a22fcf89c238892c9fabdcf97b26a0549580ed967ec", + "originPath": "plugins/components/skills/destructive-button/web/tokens.yaml", + "originBlobSha": "3075c21e58eeab117f9cd48db55ab8fcb5742ceb", + "originSha256": "f615d75ce795bacb007a8a22fcf89c238892c9fabdcf97b26a0549580ed967ec", + "contentDiffers": false + } + ], + "releaseDifferences": [] + } + ], + "divergences": [ + { + "id": "destructive-button-icon-only-shape", + "status": "accepted" + }, + { + "id": "destructive-button-mobile-secondary", + "status": "not-applicable" + }, + { + "id": "destructive-button-single-icon-slot", + "status": "deferred" + } + ], + "requirements": [ + { + "id": "DBTN-001", + "evidence": ["destructive-button.types.ts", "useDestructiveButton.ts", "destructive-button.test.tsx"] + }, + { + "id": "DBTN-002", + "evidence": ["renderDestructiveButton.tsx", "destructive-button.test.tsx", "destructive-button.stories.tsx"] + }, + { + "id": "DBTN-003", + "evidence": ["destructive-button.styles.ts", "useDestructiveButtonStyles.ts", "destructive-button.test.tsx"] + }, + { + "id": "DBTN-004", + "evidence": ["useDestructiveButton.ts", "useDestructiveButtonStyles.ts", "destructive-button.test.tsx"] + }, + { + "id": "DBTN-005", + "evidence": ["destructive-button.types.ts", "destructive-button.types.test.tsx", "destructive-button.test.tsx"] + }, + { + "id": "DBTN-006", + "evidence": ["useDestructiveButtonStyles.ts", "renderDestructiveButton.tsx", "destructive-button.test.tsx"] + } + ] +} diff --git a/packages/agentic/components/src/components/destructive-button/spec/tokens.yaml b/packages/agentic/components/src/components/destructive-button/spec/tokens.yaml new file mode 100644 index 0000000000..6ba5b3859f --- /dev/null +++ b/packages/agentic/components/src/components/destructive-button/spec/tokens.yaml @@ -0,0 +1,132 @@ +schemaVersion: 1 +component: destructive-button +implementation: destructive-button.styles.ts + +statePrecedence: + - appearance + - disabled + - pressed + - hovered + +bindings: + root: + layout: + alignItems: center + alignSelf: flex-start + borderStyle: solid + flexDirection: row + justifyContent: center + borderWidth: strokeWidth.thin + minHeight: size240 + minWidth: size240 + appearance: + primary: + backgroundColor: color.backgroundDangerLoud + borderColor: color.strokeNeutralTransparent + foreground: color.foregroundDangerOnloud + subtle: + backgroundColor: color.backgroundNeutralTransparent + borderColor: color.strokeNeutralTransparent + foreground: color.foregroundDangerPrimary + disabled: + foreground: color.foregroundNeutralDisabled + primary: + backgroundColor: color.backgroundNeutralHeavyDisabled + subtle: + backgroundColor: color.backgroundNeutralTransparent + interaction: + hovered: color.hover + pressed: color.pressed + subtleHoveredBackground: color.hover.backgroundDangerSubtle + subtlePressedBackground: color.pressed.backgroundDangerSubtle + size: + small: + roundedRadius: borderRadius.base200 + horizontalPadding: spacing.componentBase200 + verticalPadding: spacing.componentBase100 + iconOnlyPadding: spacing.componentBase100 + gap: spacing.componentBase50 + medium: + roundedRadius: borderRadius.base300 + horizontalPadding: spacing.componentBase250 + verticalPadding: spacing.componentBase150 + iconOnlyPadding: spacing.componentBase150 + gap: spacing.componentBase100 + large: + roundedRadius: borderRadius.base400 + horizontalPadding: spacing.componentBase300 + verticalPadding: spacing.componentBase200 + iconOnlyPadding: spacing.componentBase250 + gap: spacing.componentBase150 + shape: + circle: borderRadius.circular + + content: + layout: + flexShrink: 1 + textAlign: center + family: fontFamily.functional + weight: fontWeight.functionalRegular + size: + small: + fontSize: fontSize.functionalBodySmall + lineHeight: lineHeight.functionalBodySmall + medium: + fontSize: fontSize.functionalBodyMedium + lineHeight: lineHeight.functionalBodyMedium + large: + fontSize: fontSize.functionalBodyLarge + lineHeight: lineHeight.functionalBodyLarge + + icon: + small: + height: size160 + width: size160 + medium: + height: size200 + width: size200 + large: + height: size200 + width: size200 + color: resolved root foreground + inheritedFrom: button.styles.ts getButtonIconSize + + focusVisual: + innerColor: color.strokeFocusInner + innerWidth: strokeWidth.thin + outerColor: color.strokeFocusOuter + outerWidth: strokeWidth.thick + +notes: + strokes: > + Both appearances are strokeless because DestructiveButton has no outline + emphasis level. The danger signal is carried by the primary fill or by the + subtle foreground, never by a border. + subtleInteraction: > + The subtle rest background is neutral transparent, but its hovered and + pressed backgrounds resolve from the danger subtle token so the interaction + reveals a danger tint rather than a neutral backplate. + disabled: > + Disabled values reuse the neutral disabled family shared with Button so the + unavailable treatment stays consistent across the button family. + +tokenGaps: + - id: destructive-button-danger-interaction + binding: color.hover.backgroundDangerLoud, color.pressed.backgroundDangerLoud + reason: > + flex-from-theme.json maps no FURN theme value onto the danger loud + interaction backgrounds, so they fall back to the rest value. The primary + appearance therefore resolves the same background at rest, hover, and + press until the theme mapping supplies distinct danger interaction values. + - id: destructive-button-danger-foreground-interaction + binding: color.hover.foregroundDangerPrimary, color.pressed.foregroundDangerPrimary + reason: > + The danger primary foreground has no mapped interaction value, so the + subtle appearance changes only its background across interaction states. + The subtle background still changes visibly because its rest value is the + neutral transparent token rather than the danger subtle token. + - id: destructive-button-state-motion + binding: root.backgroundColor + reason: > + This package publishes no motion tokens, so interaction color changes are + applied without a duration or easing. diff --git a/packages/agentic/components/src/components/destructive-button/spec/usage.md b/packages/agentic/components/src/components/destructive-button/spec/usage.md new file mode 100644 index 0000000000..55def68f6f --- /dev/null +++ b/packages/agentic/components/src/components/destructive-button/spec/usage.md @@ -0,0 +1,60 @@ +# DestructiveButton usage + +Use DestructiveButton when the action itself causes loss or is hard to reverse: +delete, remove, discard, revoke, permanently disable. Use Button for the +overwhelming majority of actions, including negative-sounding but reversible +ones such as Cancel, Close, and Undo. + +Overusing the danger family desensitizes people to it. When everything reads as +dangerous, nothing does. + +```tsx + + + +``` + +## Appearance + +`primary` is the default and is a loud danger fill. Reserve it for the single +most consequential action on a surface: the confirm action of a delete or +discard flow. Never place two primary destructive actions on one surface, and do +not pair one beside a primary Button, because two loud fills leave the default +action ambiguous. + +`subtle` is transparent at rest with danger-colored text and reveals a danger +tint on hover. Use it for inline destructive actions such as a row action in a +list, where a loud fill would overwhelm the surrounding content. + +Pair a primary destructive confirm with a subtle or secondary neutral cancel. + +## Content + +Use a specific, consequence-revealing verb. Prefer "Delete", "Remove", or +"Discard changes" over "OK" or "Yes", especially in a confirmation dialog. The +component supplies no default label; give every instance content or an +accessible label that names the action. + +Content wraps when a consumer constrains the root. Keep destructive labels short +so a wrapped or clipped label cannot hide the real outcome. + +## Layout and size + +Small buttons suit dense surfaces such as toolbars and table row actions, +medium is the general default, and large gives a destructive action more +physical presence. Icon sizing follows size automatically; do not override it. + +Icon-only buttons need an action-oriented accessible label and visible product +context such as a tooltip. `shape` defaults to `rounded` with content and +`circle` when the button is icon-only; set it explicitly only when a surface +needs the other form. + +## Confirmation + +DestructiveButton styling communicates severity but does not prevent accidental +activation. Gate an irreversible action behind a confirmation surface or provide +an undo affordance. Do not use `disabled` to express that a destructive action +is blocked without explaining why nearby. + +DestructiveButton is a one-shot command and exposes no selection axis. A +destructive choice that must stay active is a different pattern. diff --git a/packages/agentic/components/src/components/destructive-button/useDestructiveButton.ts b/packages/agentic/components/src/components/destructive-button/useDestructiveButton.ts new file mode 100644 index 0000000000..1512a4309d --- /dev/null +++ b/packages/agentic/components/src/components/destructive-button/useDestructiveButton.ts @@ -0,0 +1,79 @@ +import type { DestructiveButtonProps, DestructiveButtonState } from './destructive-button.types'; +import { useAccessibilityLabelWarning, usePressableState, useSlot, useOptionalSlot } from '@fluentui-react-native/framework-base'; +import { useThemeState } from '@fluentui-react-native/design'; +import { Pressable } from 'react-native'; +import type { PressableProps } from 'react-native'; +import { Icon } from '../../primitives/icon/icon'; +import { Text } from '../text/text'; + +type NativeFocusPressableProps = PressableProps & { + enableFocusRing: boolean; +}; + +/** + * Hook to create the state for a DestructiveButton component. This is responsible for: + * - resolving the prop states to their default values if unset + * - setting up any accessibility for the component + * - querying the theme state for the component + * - initializing the component slots + */ +export function useDestructiveButton_unstable(props: DestructiveButtonProps): DestructiveButtonState { + const { + accessibilityState, + appearance = 'primary', + content: contentProp, + disabled = false, + icon: iconProp, + iconPosition = 'before', + ref: rootRef, + shape, + size = 'medium', + style: userStyle, + ...rest + } = props; + const hasContent = contentProp !== undefined && contentProp !== null; + const hasIcon = iconProp !== undefined && iconProp !== null; + const iconOnly = !hasContent && hasIcon; + + useAccessibilityLabelWarning({ + accessibilityLabel: rest.accessibilityLabel ?? rest['aria-label'], + accessibilityLabelledBy: rest.accessibilityLabelledBy ?? rest['aria-labelledby'], + componentName: 'DestructiveButton', + requireLabel: iconOnly, + warning: 'DestructiveButton: icon-only buttons require an accessibilityLabel that describes the action.', + }); + + const themeState = useThemeState(); + const nativeProps: NativeFocusPressableProps = { + ...rest, + role: 'button', + accessibilityState: { + ...accessibilityState, + disabled, + }, + accessible: rest.accessible ?? true, + disabled, + // RNW 0.81 crashes when either outline props or its native focus ring creates border visuals after mount. + enableFocusRing: false, + focusable: rest.focusable ?? !disabled, + }; + const [pressableProps, pressableState] = usePressableState(nativeProps); + const root = useSlot(Pressable, { ...pressableProps, ref: rootRef }); + const icon = useOptionalSlot(Icon, iconProp); + const content = useOptionalSlot(Text, contentProp); + + return { + root, + icon, + content, + disabled, + size, + shape: shape ?? (iconOnly ? 'circle' : 'rounded'), + iconPosition, + iconOnly, + userStyle, + ...themeState, + ...pressableState, + appearance, + }; +} diff --git a/packages/agentic/components/src/components/destructive-button/useDestructiveButtonStyles.ts b/packages/agentic/components/src/components/destructive-button/useDestructiveButtonStyles.ts new file mode 100644 index 0000000000..aa2280f9cf --- /dev/null +++ b/packages/agentic/components/src/components/destructive-button/useDestructiveButtonStyles.ts @@ -0,0 +1,49 @@ +import type { StyleProp, TextStyle, ViewStyle } from 'react-native'; + +import { attachSlotProps } from '@fluentui-react-native/framework-base'; +import { createFocusVisualProps_unstable } from '../../primitives/focus-visual/focus-visual'; + +import { + destructiveButtonStyles, + getDestructiveButtonColorStyles, + getDestructiveButtonContentStyle, + getDestructiveButtonIconSize, + getDestructiveButtonRootStyle, +} from './destructive-button.styles'; +import type { DestructiveButtonState } from './destructive-button.types'; + +/** + * Applies stable theme styles and instance-specific style selections to the + * destructive button slots. + */ +export function useDestructiveButtonStyles_unstable(state: DestructiveButtonState) { + const { size, userStyle } = state; + const colors = getDestructiveButtonColorStyles(state); + const rootLayoutStyle = getDestructiveButtonRootStyle(state); + const rootStyle: StyleProp = [destructiveButtonStyles.root, rootLayoutStyle, colors.background, userStyle]; + const contentStyle: StyleProp = [destructiveButtonStyles.content, getDestructiveButtonContentStyle(state), colors.foreground]; + const iconSize = getDestructiveButtonIconSize(size); + + state.focusVisualProps = createFocusVisualProps_unstable({ + borderRadius: rootLayoutStyle.borderRadius, + innerColor: state.tokens.color.strokeFocusInner, + innerWidth: state.tokens.strokeWidth.thin, + outerColor: state.tokens.color.strokeFocusOuter, + outerWidth: state.tokens.strokeWidth.thick, + visible: state.focused && !state.disabled, + }); + attachSlotProps(state.root, { style: rootStyle }); + if (state.icon) { + attachSlotProps(state.icon, { + accessible: false, + color: colors.foreground.color, + height: iconSize, + width: iconSize, + }); + } + if (state.content) { + attachSlotProps(state.content, { + style: contentStyle, + }); + } +} diff --git a/packages/agentic/components/src/index.test.ts b/packages/agentic/components/src/index.test.ts index 2fbc9856b8..29741710bd 100644 --- a/packages/agentic/components/src/index.test.ts +++ b/packages/agentic/components/src/index.test.ts @@ -11,6 +11,7 @@ describe('component exports', () => { 'Button', 'Card', 'Checkbox', + 'DestructiveButton', 'Divider', 'Input', 'ListItem', @@ -32,6 +33,7 @@ describe('component exports', () => { 'renderButton_unstable', 'renderCard_unstable', 'renderCheckbox_unstable', + 'renderDestructiveButton_unstable', 'renderDivider_unstable', 'renderInput_unstable', 'renderListItem_unstable', @@ -60,6 +62,8 @@ describe('component exports', () => { 'useCard_unstable', 'useCheckboxStyles_unstable', 'useCheckbox_unstable', + 'useDestructiveButtonStyles_unstable', + 'useDestructiveButton_unstable', 'useDividerStyles_unstable', 'useDivider_unstable', 'useInputStyles_unstable', diff --git a/packages/agentic/components/src/index.ts b/packages/agentic/components/src/index.ts index 5fa8f90c6b..d857a79aed 100644 --- a/packages/agentic/components/src/index.ts +++ b/packages/agentic/components/src/index.ts @@ -69,6 +69,20 @@ export { renderCheckbox_unstable } from './components/checkbox/renderCheckbox'; export { useCheckboxStyles_unstable } from './components/checkbox/useCheckboxStyles'; export { useCheckbox_unstable } from './components/checkbox/useCheckbox'; +export { DestructiveButton } from './components/destructive-button/destructive-button'; +export type { + DestructiveButtonAppearance, + DestructiveButtonIconPosition, + DestructiveButtonProps, + DestructiveButtonShape, + DestructiveButtonSize, + DestructiveButtonSlots, + DestructiveButtonState, +} from './components/destructive-button/destructive-button.types'; +export { renderDestructiveButton_unstable } from './components/destructive-button/renderDestructiveButton'; +export { useDestructiveButtonStyles_unstable } from './components/destructive-button/useDestructiveButtonStyles'; +export { useDestructiveButton_unstable } from './components/destructive-button/useDestructiveButton'; + export { Divider } from './components/divider/divider'; export type { DividerLayout, DividerProps, DividerSlots, DividerState } from './components/divider/divider.types'; export { renderDivider_unstable } from './components/divider/renderDivider'; diff --git a/packages/agentic/components/src/refs.types.test.tsx b/packages/agentic/components/src/refs.types.test.tsx index f8c6a88944..f5c77c0cbc 100644 --- a/packages/agentic/components/src/refs.types.test.tsx +++ b/packages/agentic/components/src/refs.types.test.tsx @@ -10,6 +10,7 @@ import { Button, Card, Checkbox, + DestructiveButton, Divider, Input, ListItem, @@ -42,6 +43,7 @@ function ComponentsWithNativeRootRefs() {