diff --git a/.changeset/olive-jars-visit.md b/.changeset/olive-jars-visit.md new file mode 100644 index 0000000000..769b542dcf --- /dev/null +++ b/.changeset/olive-jars-visit.md @@ -0,0 +1,5 @@ +--- +"@fluentui-react-native/components": minor +--- + +Add the NavItem component: a pressable navigation row with density and nesting rhythm, externally driven selection and category disclosure, a collapsed icon rail mode, and trailing content and action regions. diff --git a/.changeset/olive-labels-name.md b/.changeset/olive-labels-name.md new file mode 100644 index 0000000000..d9668c1be0 --- /dev/null +++ b/.changeset/olive-labels-name.md @@ -0,0 +1,5 @@ +--- +"@fluentui-react-native/components": minor +--- + +Add the agentic Label component for naming an associated form control. diff --git a/.changeset/plain-links-navigate.md b/.changeset/plain-links-navigate.md new file mode 100644 index 0000000000..ce975e1254 --- /dev/null +++ b/.changeset/plain-links-navigate.md @@ -0,0 +1,5 @@ +--- +"@fluentui-react-native/components": minor +--- + +Add the agentic Link component with functional and content type sets, inline and standalone layouts, an optional trailing icon, and platform navigation through React Native Linking. diff --git a/.changeset/popover-agentic-component.md b/.changeset/popover-agentic-component.md new file mode 100644 index 0000000000..d3a9aac316 --- /dev/null +++ b/.changeset/popover-agentic-component.md @@ -0,0 +1,5 @@ +--- +"@fluentui-react-native/components": minor +--- + +Add the agentic Popover component: a trigger paired with a native floating surface built on Callout, with owned open state, tokenized surface styling, and documented per-platform limits. diff --git a/.changeset/shared-components-align.md b/.changeset/shared-components-align.md new file mode 100644 index 0000000000..38a857e624 --- /dev/null +++ b/.changeset/shared-components-align.md @@ -0,0 +1,7 @@ +--- +"@fluentui-react-native/components": patch +"@fluentui-react-native/design": patch +"@fluentui-react-native/framework-base": patch +--- + +Share component accessibility, interaction, warning, and numeric style policies, and migrate component semantics to the ARIA-aligned `role` prop. diff --git a/.changeset/smooth-queries-clear.md b/.changeset/smooth-queries-clear.md new file mode 100644 index 0000000000..7c17a64791 --- /dev/null +++ b/.changeset/smooth-queries-clear.md @@ -0,0 +1,5 @@ +--- +"@fluentui-react-native/components": minor +--- + +Add the agentic SearchBox component: an Input-backed query field with a leading search icon, a clear button that appears only while a query is present, and search, clear, disabled, and read-only semantics. diff --git a/.changeset/steady-surfaces-center.md b/.changeset/steady-surfaces-center.md new file mode 100644 index 0000000000..e8549e842b --- /dev/null +++ b/.changeset/steady-surfaces-center.md @@ -0,0 +1,5 @@ +--- +"@fluentui-react-native/components": patch +--- + +Keep Popover and Tooltip surfaces readable when native callouts measure unconstrained text, and keep Input and SearchBox text vertically centered after clearing a focused value. 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/.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/.changeset/tidy-tags-interact.md b/.changeset/tidy-tags-interact.md new file mode 100644 index 0000000000..58ca7dfc8e --- /dev/null +++ b/.changeset/tidy-tags-interact.md @@ -0,0 +1,5 @@ +--- +'@fluentui-react-native/components': minor +--- + +Add the agentic InteractionTag component with independently pressable primary and dismiss regions, exclusive leading icon or avatar content, and per-region focus visuals. diff --git a/.changeset/tooltip-agentic-component.md b/.changeset/tooltip-agentic-component.md new file mode 100644 index 0000000000..4aa12f0d82 --- /dev/null +++ b/.changeset/tooltip-agentic-component.md @@ -0,0 +1,5 @@ +--- +"@fluentui-react-native/components": minor +--- + +Add the agentic Tooltip component: a trigger paired with a short descriptive label that is revealed on pointer entry after a delay and on keyboard focus, built on the reviewed Popover pipeline with owned visibility, trigger description semantics, tokenized label styling, and documented per-platform limits. diff --git a/.github/skills/agentic-component-authoring/references/state-and-accessibility.md b/.github/skills/agentic-component-authoring/references/state-and-accessibility.md index 2587845514..0ff3d556e2 100644 --- a/.github/skills/agentic-component-authoring/references/state-and-accessibility.md +++ b/.github/skills/agentic-component-authoring/references/state-and-accessibility.md @@ -101,14 +101,20 @@ accessibilityState: { The component must own its role and state semantics while preserving unrelated consumer values such as `busy`. -- Set the native role explicitly. +- Set native semantics with React Native's ARIA-aligned `role` prop, not the legacy `accessibilityRole`. Use ARIA names + such as `img` and `heading`, not legacy names such as `image` and `header`. +- Omit both `role` and `accessibilityRole` from public native props when the component owns the role. If a caller owns + the semantic choice, expose only `role`. - Keep disabled state, focusability, and interaction behavior consistent. - Prefer a consumer-provided `accessible` or `focusable` value only when it does not violate the component contract. + Disabled state is authoritative: resolve focusability with the shared component interaction helper so + `focusable={true}` cannot keep a disabled control in the focus order. - Add selected or checked semantics only when the corresponding behavior is enabled. - Use action-oriented accessible names for icon-only controls. -Button warns in development when an icon-only instance lacks an `accessibilityLabel`. Put warnings in an effect so -render remains free of observable side effects, and make the dependency list match every value used by the warning. +Button warns in development when an icon-only instance lacks an `accessibilityLabel`. Use `useDevWarning` from +`@fluentui-react-native/framework-base` for conditional usage warnings so render remains free of observable side +effects and repeated renders do not emit duplicate messages. ## Use framework interaction and slot hooks diff --git a/.github/skills/agentic-component-authoring/references/styles-and-tokens.md b/.github/skills/agentic-component-authoring/references/styles-and-tokens.md index 8198cf5807..86026df11c 100644 --- a/.github/skills/agentic-component-authoring/references/styles-and-tokens.md +++ b/.github/skills/agentic-component-authoring/references/styles-and-tokens.md @@ -97,7 +97,8 @@ Destructure only the token groups needed by a factory: ``` Validate token values when their generated type is wider than the React Native style property. Button validates its gap -token before assigning it to `ViewStyle['gap']`. +token before assigning it to `ViewStyle['gap']`. Use `getNumericStyleValueAsNumber` when the target property requires a +number; it centralizes validation and numeric-string coercion instead of repeating `Number(getNumericStyleValue(...))`. ## Apply slot props in one stage diff --git a/.github/skills/agentic-component-authoring/references/tests-and-stories.md b/.github/skills/agentic-component-authoring/references/tests-and-stories.md index 3c1069e2d4..1d9806dd69 100644 --- a/.github/skills/agentic-component-authoring/references/tests-and-stories.md +++ b/.github/skills/agentic-component-authoring/references/tests-and-stories.md @@ -12,6 +12,7 @@ Use `@testing-library/react-native`. Do not import `react-test-renderer` directl Prefer public semantics: - query the root by accessibility role +- assert the ARIA-aligned `role` prop rather than the legacy `accessibilityRole` - use matchers such as `toBeDisabled()` - assert `accessibilityState`, accessible names, and focusability - use async `fireEvent` calls for hover, press, focus, and user handlers diff --git a/.github/skills/agentic-component-contract-authoring/references/sources/flex-x3.md b/.github/skills/agentic-component-contract-authoring/references/sources/flex-x3.md index 1a4180acd8..5e66933fe3 100644 --- a/.github/skills/agentic-component-contract-authoring/references/sources/flex-x3.md +++ b/.github/skills/agentic-component-contract-authoring/references/sources/flex-x3.md @@ -35,7 +35,7 @@ Translate web concepts deliberately: | Flex evidence | React Native contract | | ------------------------------------ | ---------------------------------------------------------------------------------- | -| ARIA attributes or native elements | `accessibilityRole`, `accessibilityState`, labels, and UIA/AX expectations | +| ARIA attributes or native elements | ARIA-aligned `role`, `accessibilityState`, labels, and UIA/AX expectations | | CSS pseudo-classes | explicit interaction state and precedence | | browser focus selectors and outlines | persistent `FocusVisual`; never conditional `outline*` or RNW native focus visuals | | CSS pixel or layout rules | React Native units, minimum targets, and platform behavior | diff --git a/packages/agentic/components/AGENTS.md b/packages/agentic/components/AGENTS.md index ec8dde4746..eb311eb113 100644 --- a/packages/agentic/components/AGENTS.md +++ b/packages/agentic/components/AGENTS.md @@ -37,6 +37,9 @@ invariants; detailed authoring recipes live in the - Use `src/components/button` as the canonical higher-order implementation and `src/primitives/icon` as the canonical primitive. - Keep public props and slots small, typed, and spec-driven. +- Express native semantics with React Native's ARIA-aligned `role` prop, not the legacy `accessibilityRole`. When a + component owns its semantics, omit both props from its public native surface; expose only `role` when callers own the + semantic choice. - This package targets React 19.1.4 or newer. Expose a stable native root ref through the `ref` prop, receive it as part of the component props, and forward it to the declared root slot. Do not use `forwardRef`. - Export higher-order components and public types explicitly from `src/index.ts`; export primitives and their public types diff --git a/packages/agentic/components/package.json b/packages/agentic/components/package.json index 089a38cac4..0aa825dbcf 100644 --- a/packages/agentic/components/package.json +++ b/packages/agentic/components/package.json @@ -87,12 +87,7 @@ } }, "furn": { - "jestPlatform": "macos", - "knip": { - "ignoreDependencies": [ - "@fluentui-react-native/callout" - ] - } + "jestPlatform": "macos" }, "rnx-kit": { "kitType": "library", diff --git a/packages/agentic/components/spec-source-report.json b/packages/agentic/components/spec-source-report.json index 05fe03b7d0..84abf2fa6e 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-03T16:21:33.572Z", "sourceLock": "flex-1.5.0-206c4996", "sourceLockFingerprint": "a69997212ec1b89510c94176801bf5a146ed7e7d8c80cc7db40ac8f60cf9f119", "baseline": { @@ -10,8 +10,8 @@ "external": { "mode": "live", "status": "drift-detected", - "marketplaceHead": "eb8cf20cb1c6ce4fdc563ac1ebd34197cb347735", - "originHead": "dfdaeba2f79c2c2c33cdc074cf1e60f1c3a2929c" + "marketplaceHead": "686dbdd4768d1238cdda0649d5b9dfa794c7c381", + "originHead": "cbe3cca9207ac0c24c3f965ec483788ee5e11c44" }, "catalog": { "releaseEntries": [ @@ -126,111 +126,111 @@ "contracts": [ "accordion", "avatar", + "avatar-group", "badge", "button", "card", "checkbox", + "destructive-button", "divider", "input", + "interaction-tag", + "label", + "link", "list-item", "listbox-item", "menu-item", + "nav-item", + "popover", "progress-bar", "radio", + "search-box", "skeleton", "spinner", "switch", "tab", "tablist", "tag", - "text" + "text", + "tooltip" ], "adaptedDrafts": [], "implemented": [ "accordion", "avatar", + "avatar-group", "badge", "button", "card", "checkbox", + "destructive-button", "divider", "input", + "interaction-tag", + "label", + "link", "list-item", "listbox-item", "menu-item", + "nav-item", + "popover", "progress-bar", "radio", + "search-box", "skeleton", "spinner", "switch", "tab", "tablist", "tag", - "text" + "text", + "tooltip" ], "implementationGap": [ - "avatar-group", "breadcrumb", "combobox", - "destructive-button", "dialog", "drawer", "dropdown", "field", "info-label", - "interaction-tag", - "label", - "link", "list", "menu", "menu-button", "message-bar", "nav", - "nav-item", - "popover", "radio-group", "scrollbar", - "search-box", "select", "simple-nav", "split-button", "teaching-popover", "textarea", "toggle-button", - "toolbar", - "tooltip" + "toolbar" ], "noLocalContract": [ - "avatar-group", "breadcrumb", "combobox", - "destructive-button", "dialog", "drawer", "dropdown", "field", "info-label", - "interaction-tag", - "label", - "link", "list", "menu", "menu-button", "message-bar", "nav", - "nav-item", - "popover", "radio-group", "scrollbar", - "search-box", "select", "simple-nav", "split-button", "teaching-popover", "textarea", "toggle-button", - "toolbar", - "tooltip" + "toolbar" ], "legacyMigration": [ { @@ -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", @@ -536,6 +553,23 @@ }, "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" + }, { "component": "divider", "lifecycle": "implemented", @@ -577,6 +611,57 @@ }, "candidateStatus": "review-required" }, + { + "component": "interaction-tag", + "lifecycle": "implemented", + "conformance": "reviewed", + "releaseDifferences": [], + "marketplaceDrift": { + "added": [], + "removed": [], + "modified": [] + }, + "originDrift": { + "added": [], + "removed": [], + "modified": ["web/accessibility.md"] + }, + "candidateStatus": "review-required" + }, + { + "component": "label", + "lifecycle": "implemented", + "conformance": "reviewed", + "releaseDifferences": [], + "marketplaceDrift": { + "added": [], + "removed": [], + "modified": [] + }, + "originDrift": { + "added": [], + "removed": [], + "modified": [] + }, + "candidateStatus": "current" + }, + { + "component": "link", + "lifecycle": "implemented", + "conformance": "reviewed", + "releaseDifferences": [], + "marketplaceDrift": { + "added": [], + "removed": [], + "modified": [] + }, + "originDrift": { + "added": [], + "removed": [], + "modified": ["web/accessibility.md", "web/interaction.md"] + }, + "candidateStatus": "review-required" + }, { "component": "list-item", "lifecycle": "implemented", @@ -628,6 +713,40 @@ }, "candidateStatus": "review-required" }, + { + "component": "nav-item", + "lifecycle": "implemented", + "conformance": "reviewed", + "releaseDifferences": [], + "marketplaceDrift": { + "added": [], + "removed": [], + "modified": [] + }, + "originDrift": { + "added": [], + "removed": [], + "modified": ["web/accessibility.md", "web/interaction.md"] + }, + "candidateStatus": "review-required" + }, + { + "component": "popover", + "lifecycle": "implemented", + "conformance": "reviewed", + "releaseDifferences": [], + "marketplaceDrift": { + "added": [], + "removed": [], + "modified": [] + }, + "originDrift": { + "added": [], + "removed": [], + "modified": ["SKILL.md", "usage.md", "web/interaction.md", "web/tokens.yaml"] + }, + "candidateStatus": "review-required" + }, { "component": "progress-bar", "lifecycle": "implemented", @@ -662,6 +781,23 @@ }, "candidateStatus": "review-required" }, + { + "component": "search-box", + "lifecycle": "implemented", + "conformance": "reviewed", + "releaseDifferences": [], + "marketplaceDrift": { + "added": [], + "removed": [], + "modified": [] + }, + "originDrift": { + "added": [], + "removed": [], + "modified": [] + }, + "candidateStatus": "current" + }, { "component": "skeleton", "lifecycle": "implemented", @@ -772,6 +908,23 @@ "marketplaceDrift": null, "originDrift": null, "candidateStatus": "not-applicable" + }, + { + "component": "tooltip", + "lifecycle": "implemented", + "conformance": "reviewed", + "releaseDifferences": [], + "marketplaceDrift": { + "added": [], + "removed": [], + "modified": [] + }, + "originDrift": { + "added": [], + "removed": [], + "modified": ["web/tokens.yaml"] + }, + "candidateStatus": "review-required" } ] } diff --git a/packages/agentic/components/src/AGENTS.md b/packages/agentic/components/src/AGENTS.md index 14f6f6506a..ec186da072 100644 --- a/packages/agentic/components/src/AGENTS.md +++ b/packages/agentic/components/src/AGENTS.md @@ -14,11 +14,16 @@ the change crosses component boundaries. ## Source boundaries -- Production source may depend only on React / React Native native components, `@fluentui-react-native/design`, and - `@fluentui-react-native/framework-base`. +- Production source may depend only on React / React Native native components, `@fluentui-react-native/design`, + `@fluentui-react-native/framework-base`, and `@fluentui-react-native/callout`. Callout is allowed because a floating + surface has to be a native popup window on Windows and macOS, and no equivalent exists in React Native or in the + framework packages. Adding another native component package requires the same kind of justification and an update + here. - Generalizable non-styling hooks belong in `framework-base/src/hooks`. - Styling helpers belong in `agentic/design/src/styling`. - Component-library-specific non-public types, constants, and helpers belong in `src/common`. +- Reuse `src/common/accessibility.ts` for accessible-name detection and `src/common/interaction.ts` for disabled + focusability and native focus-ring policy instead of repeating those decisions in component hooks. - Primitives are public from `@fluentui-react-native/components/primitives`, must remain unstyled, and require a colocated `CONTRACT.md`. Extract one only for a repeated behavioral or structural contract that is useful to consumers; keep package-private helpers in `src/common`. diff --git a/packages/agentic/components/src/common/accessibility.test.ts b/packages/agentic/components/src/common/accessibility.test.ts new file mode 100644 index 0000000000..d33da9eb5b --- /dev/null +++ b/packages/agentic/components/src/common/accessibility.test.ts @@ -0,0 +1,16 @@ +import { hasAccessibleName } from './accessibility'; + +describe('hasAccessibleName', () => { + it.each([ + [{ accessibilityLabel: 'Avatar' }], + [{ accessibilityLabelledBy: 'avatar-label' }], + [{ 'aria-label': 'Avatar' }], + [{ 'aria-labelledby': 'avatar-label' }], + ])('detects an accessible name from %o', (props) => { + expect(hasAccessibleName(props)).toBe(true); + }); + + it('returns false when no naming property is present', () => { + expect(hasAccessibleName({})).toBe(false); + }); +}); diff --git a/packages/agentic/components/src/common/accessibility.ts b/packages/agentic/components/src/common/accessibility.ts index c392f980ae..1a99fe4677 100644 --- a/packages/agentic/components/src/common/accessibility.ts +++ b/packages/agentic/components/src/common/accessibility.ts @@ -1,11 +1,25 @@ import type { AccessibilityProps } from 'react-native'; +export type AccessibleNameProps = Pick< + AccessibilityProps, + 'accessibilityLabel' | 'accessibilityLabelledBy' | 'aria-label' | 'aria-labelledby' +>; + export const hiddenFromAccessibilityProps = { accessibilityElementsHidden: true, accessible: false, importantForAccessibility: 'no-hide-descendants', } as const satisfies AccessibilityProps; +export function hasAccessibleName(props: AccessibleNameProps): boolean { + return ( + props.accessibilityLabel !== undefined || + props.accessibilityLabelledBy !== undefined || + props['aria-label'] !== undefined || + props['aria-labelledby'] !== undefined + ); +} + export function hideSlotProps(props: TProps): TProps { return { ...props, diff --git a/packages/agentic/components/src/common/iconSources.ts b/packages/agentic/components/src/common/iconSources.ts index 5629bd8a0d..0053e3e3b7 100644 --- a/packages/agentic/components/src/common/iconSources.ts +++ b/packages/agentic/components/src/common/iconSources.ts @@ -7,6 +7,7 @@ export const semanticIconSources = { error: { codepoint: 0x2716 }, indeterminate: { codepoint: 0x2212 }, person: { codepoint: 0x1f464 }, + search: { codepoint: 0x1f50d }, selectedCircle: { codepoint: 0x25cf }, unselectedCircle: { codepoint: 0x25cb }, } as const satisfies Record; diff --git a/packages/agentic/components/src/common/interaction.test.ts b/packages/agentic/components/src/common/interaction.test.ts new file mode 100644 index 0000000000..13c25beb1f --- /dev/null +++ b/packages/agentic/components/src/common/interaction.test.ts @@ -0,0 +1,18 @@ +import { disableNativeFocusRingProps, resolveFocusable } from './interaction'; + +describe('interaction helpers', () => { + it('keeps disabled controls out of the focus order', () => { + expect(resolveFocusable(true, true)).toBe(false); + expect(resolveFocusable(undefined, true)).toBe(false); + }); + + it('respects explicit focusability for enabled controls', () => { + expect(resolveFocusable(false, false)).toBe(false); + expect(resolveFocusable(true, false)).toBe(true); + expect(resolveFocusable(undefined, false)).toBe(true); + }); + + it('disables the native focus ring', () => { + expect(disableNativeFocusRingProps).toEqual({ enableFocusRing: false }); + }); +}); diff --git a/packages/agentic/components/src/common/interaction.ts b/packages/agentic/components/src/common/interaction.ts new file mode 100644 index 0000000000..fff4a52c1e --- /dev/null +++ b/packages/agentic/components/src/common/interaction.ts @@ -0,0 +1,16 @@ +import type { PressableProps } from 'react-native'; + +export type NativeFocusPressableProps = PressableProps & { + enableFocusRing: boolean; +}; + +/** + * RNW 0.81 crashes when its native focus ring creates border visuals after mount. + */ +export const disableNativeFocusRingProps = { + enableFocusRing: false, +} as const satisfies Pick; + +export function resolveFocusable(focusable: boolean | undefined, disabled: boolean): boolean { + return !disabled && (focusable ?? true); +} diff --git a/packages/agentic/components/src/components/AGENTS.md b/packages/agentic/components/src/components/AGENTS.md index 104b6aa11d..ec350624d2 100644 --- a/packages/agentic/components/src/components/AGENTS.md +++ b/packages/agentic/components/src/components/AGENTS.md @@ -35,6 +35,8 @@ audit. - Keep render-only slots private to state. - Exclude native `children` or other props the component owns. - Preserve the distinction between omitted and false controlled values. +- Set native semantics with the ARIA-aligned `role` prop, never `accessibilityRole`. Omit both from public root or slot + props when the component owns the role; when callers own it, expose only `role`. - Classify every stateful axis before wiring it. A self-driving control, where the interaction _is_ the state change, supports both directions through the `` / `default` / `onChange` triple and `useToggleState` from `framework-base`. Externally driven selection exposes only `selected`, never changes it, and reports the @@ -47,7 +49,8 @@ audit. `ref` as a prop, so pass it through the state hook to the root slot without `forwardRef`. If the component also needs an internal root ref, compose the refs through the slot render path rather than replacing the consumer ref. - Render focus feedback through `FocusVisual`; do not add `outline*` props or enable RNW native - focus visuals because RNW 0.81 can fail-fast when either path creates border visuals after mount. + focus visuals because RNW 0.81 can fail-fast when either path creates border visuals after mount. Reuse the shared + interaction policy for native focus-ring suppression and disabled-wins focusability. - Keep render functions free of hooks, token reads, style creation, and slot mutation. - Export the resolved state type and the state, style-application, and render stages from the package root under component-qualified unstable names so another component can reuse the pipeline. diff --git a/packages/agentic/components/src/components/accordion/accordion.test.tsx b/packages/agentic/components/src/components/accordion/accordion.test.tsx index 9a1bda6483..d64c9888c9 100644 --- a/packages/agentic/components/src/components/accordion/accordion.test.tsx +++ b/packages/agentic/components/src/components/accordion/accordion.test.tsx @@ -37,7 +37,7 @@ describe('Accordion', () => { const header = getHeader(component); const body = getBody(component); - expect(header.props.accessibilityRole).toBe('button'); + expect(header.props.role).toBe('button'); expect(header.props.accessibilityState).toEqual({ expanded: false }); expect(component.getByText('Section title')).toBeOnTheScreen(); expect(component.getByTestId('accordion-leading-icon').props.style).toMatchObject({ height: 16, width: 16 }); diff --git a/packages/agentic/components/src/components/accordion/accordion.types.ts b/packages/agentic/components/src/components/accordion/accordion.types.ts index 839b45a290..67d4ace348 100644 --- a/packages/agentic/components/src/components/accordion/accordion.types.ts +++ b/packages/agentic/components/src/components/accordion/accordion.types.ts @@ -88,7 +88,7 @@ export type AccordionStateProps = { export type AccordionRootProps = OwnedRootProps< PropsWithRefOf, - 'accessible' | 'accessibilityHint' | 'accessibilityLabel' | 'accessibilityRole' | 'accessibilityState' | 'focusable' + 'accessible' | 'accessibilityHint' | 'accessibilityLabel' | 'accessibilityRole' | 'accessibilityState' | 'focusable' | 'role' >; export type AccordionProps = AccordionStateProps & ComponentProps; diff --git a/packages/agentic/components/src/components/accordion/useAccordion.ts b/packages/agentic/components/src/components/accordion/useAccordion.ts index b5aec76a07..f931e93a72 100644 --- a/packages/agentic/components/src/components/accordion/useAccordion.ts +++ b/packages/agentic/components/src/components/accordion/useAccordion.ts @@ -58,7 +58,7 @@ export function useAccordion_unstable(props: AccordionProps): AccordionState { accessibilityControls: bodyId, accessibilityHint, accessibilityLabel, - accessibilityRole: 'button', + role: 'button', accessibilityState: { ...accessibilityState, expanded: resolvedExpanded, 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..bbe4a8f8b0 --- /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 `role="img"`, 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 `role="none"` and stays a plain layout row, so each Avatar child announces its own accessible name in source order. Callers can still set `accessible` and the ARIA-aligned `role` 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.** A labelled FURN group uses `role="img"`, 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..4298e26d78 --- /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, + getNumericStyleValueAsNumber as toNumber, + 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, +}; + +/** + * 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..17d58ecf60 --- /dev/null +++ b/packages/agentic/components/src/components/avatar-group/avatar-group.test.tsx @@ -0,0 +1,333 @@ +/** @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('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 }); + + 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.role).toBe('img'); + expect(root.props.accessibilityLabel).toBe('Document collaborators: 8 people'); + expect(component.getAllByRole('img')).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('img')).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.role).toBe('none'); + expect(component.getAllByRole('img')).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.role).toBe('img'); + 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({ role: 'summary', accessible: true }); + const root = component.getByTestId('group'); + + expect(root.props.accessible).toBe(true); + expect(root.props.role).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..3f0b93e463 --- /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: { role: '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..e64060e51a --- /dev/null +++ b/packages/agentic/components/src/components/avatar-group/avatar-group.types.ts @@ -0,0 +1,86 @@ +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, 'accessibilityRole'>; + +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 & { + /** 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; + + /** 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..67cef3c550 --- /dev/null +++ b/packages/agentic/components/src/components/avatar-group/renderAvatarGroup.tsx @@ -0,0 +1,23 @@ +/** @jsxImportSource @fluentui-react-native/framework-base */ +import { View } from 'react-native'; + +import type { AvatarGroupState } from './avatar-group.types'; + +export function renderAvatarGroup_unstable(state: AvatarGroupState) { + const { itemAccessibilityProps, itemOffsetStyle, itemStyle, items, overflow: Overflow, overflowText: OverflowText } = state; + + return ( + + {items.map((item, index) => ( + + {item.node} + + ))} + {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..cf35dc1f1c --- /dev/null +++ b/packages/agentic/components/src/components/avatar-group/spec/accessibility.md @@ -0,0 +1,9 @@ +# AvatarGroup accessibility + +Give the group an accessible name when the cohort should announce once. The root then becomes accessible with `role="img"`, 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 `role="none"`, 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 the ARIA-aligned `role` 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 accessible name, such as `accessibilityLabel="5 more"`, when the group is unlabelled and the hidden count must still be heard; the indicator then becomes accessible with `role="img"`. 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..b4cefa6fb8 --- /dev/null +++ b/packages/agentic/components/src/components/avatar-group/useAvatarGroup.ts @@ -0,0 +1,124 @@ +import * as React from 'react'; +import { View } from 'react-native'; + +import { useThemeState } from '@fluentui-react-native/design'; +import { useDevWarning, useOptionalSlot, useSlot } from '@fluentui-react-native/framework-base'; + +import { hasAccessibleName, 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)}`; +} + +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; +} + +export function useAvatarGroup_unstable(props: AvatarGroupProps): AvatarGroupState { + const { + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, + accessibilityLabel, + accessibilityLabelledBy, + accessible, + children, + layout = 'spread', + overflow: overflowProp, + overflowCount: overflowCountProp = 0, + size = 40, + style: userStyle, + role, + ...rest + } = props; + + const overflowCount = normalizeOverflowCount(overflowCountProp); + 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, items } = React.useMemo(() => { + let mismatch = false; + 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, items: resolvedItems }; + }, [children, size]); + + const renderedItems = items.length + (showOverflow ? 1 : 0); + const suppressedOverflow = hasOverflow && size === 16; + + useDevWarning(hasSizeMismatch, 'AvatarGroup: every child avatar should use the same size as the group.'); + useDevWarning(suppressedOverflow, 'AvatarGroup: size 16 omits the overflow indicator, so expose the hidden count in accessibilityLabel.'); + useDevWarning( + renderedItems > maximumRenderedItems, + `AvatarGroup: render at most ${maximumRenderedItems} items and move the rest into overflowCount.`, + ); + + const themeState = useThemeState(); + const root = useSlot(View, { + ...rest, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, + accessible: isAccessible, + accessibilityLabel, + accessibilityLabelledBy, + role: role ?? (isAccessible ? 'img' : 'none'), + }); + + const overflow = useOptionalSlot(View, showOverflow ? (overflowProp ?? {}) : null, { + transform: (slotProps) => { + const isSelfLabeled = !isAccessible && hasAccessibleName(slotProps); + return isSelfLabeled + ? { ...slotProps, accessible: slotProps.accessible ?? true, role: slotProps.role ?? 'img' } + : { ...slotProps, ...hiddenFromAccessibilityProps }; + }, + }); + + const overflowText = useOptionalSlot(Text, showOverflow ? { children: overflowLabel } : null); + + return { + root, + overflow, + overflowText, + items, + 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/components/avatar/SPEC.md b/packages/agentic/components/src/components/avatar/SPEC.md index 8051a191dd..dfa6d4cd49 100644 --- a/packages/agentic/components/src/components/avatar/SPEC.md +++ b/packages/agentic/components/src/components/avatar/SPEC.md @@ -35,7 +35,7 @@ The resolved state retains size, activity-ring value, content mode, theme state, ## Platform behavior -An Avatar with `accessibilityLabel` is accessible with React Native image role; callers can also explicitly control `accessible`. Without an informative label, the default root is hidden from accessibility descendants. Its image, icon, and initials children are always hidden so identity is not announced twice. +An Avatar with an explicit accessible name is exposed with `role="img"`; callers can also explicitly control `accessible`. Without an informative name, the default root is hidden from accessibility descendants. Its image, icon, and initials children are always hidden so identity is not announced twice. Windows exposes an informative root as a UI Automation image; macOS exposes it as an AX image. Avatar adds no tab stop or `FocusVisual`, although a caller can opt the forwarded root into focus with `focusable`. The active ring uses React Native root outline properties and does not introduce a separate rendered child or change the requested width and height. diff --git a/packages/agentic/components/src/components/avatar/avatar.test.tsx b/packages/agentic/components/src/components/avatar/avatar.test.tsx index 04a88a2eb5..4864fe54f7 100644 --- a/packages/agentic/components/src/components/avatar/avatar.test.tsx +++ b/packages/agentic/components/src/components/avatar/avatar.test.tsx @@ -14,26 +14,35 @@ function renderAvatar(props: React.ComponentProps): Promise { it('renders the default icon fallback as a decorative image avatar', async () => { const component = await renderAvatar({}); - expect(component.queryByRole('image')).toBeNull(); + expect(component.queryByRole('img')).toBeNull(); expect(component.getByText(String.fromCodePoint(0x1f464), { includeHiddenElements: true })).toBeOnTheScreen(); }); it('renders informative avatars with the image role and label', async () => { const component = await renderAvatar({ accessibilityLabel: 'Lydia Mitchelson', initials: 'LM' }); - const root = component.getByRole('image'); + const root = component.getByRole('img'); expect(root.props.accessibilityLabel).toBe('Lydia Mitchelson'); expect(root.props.accessible).toBe(true); expect(component.getByText('LM', { includeHiddenElements: true })).toBeOnTheScreen(); }); + it.each([{ accessibilityLabelledBy: 'avatar-label' }, { 'aria-label': 'Lydia' }, { 'aria-labelledby': 'avatar-label' }])( + 'recognizes every supported programmatic name', + async (nameProps) => { + const component = await renderAvatar(nameProps); + + expect(component.getByRole('img')).toBeOnTheScreen(); + }, + ); + it('uses the initials mode when initials are provided', async () => { const component = await renderAvatar({ accessibilityLabel: 'Lydia Mitchelson', initials: { children: 'lm' } }); const tokens = defaultFlexTokens; diff --git a/packages/agentic/components/src/components/avatar/avatar.types.ts b/packages/agentic/components/src/components/avatar/avatar.types.ts index dae8bc011c..00ff4895b2 100644 --- a/packages/agentic/components/src/components/avatar/avatar.types.ts +++ b/packages/agentic/components/src/components/avatar/avatar.types.ts @@ -34,7 +34,7 @@ export type AvatarStateProps = { size?: AvatarSize; }; -export type AvatarRootProps = OwnedRootProps>; +export type AvatarRootProps = OwnedRootProps, 'accessibilityRole' | 'role'>; export type AvatarProps = AvatarStateProps & ComponentProps; diff --git a/packages/agentic/components/src/components/avatar/spec/accessibility.md b/packages/agentic/components/src/components/avatar/spec/accessibility.md index 09a688c015..89b4f5b449 100644 --- a/packages/agentic/components/src/components/avatar/spec/accessibility.md +++ b/packages/agentic/components/src/components/avatar/spec/accessibility.md @@ -1,6 +1,10 @@ # Avatar accessibility -Provide `accessibilityLabel` when the avatar is the identity exposed to assistive technology. The root then uses React Native image role and presents that label. When adjacent text already identifies the person or entity, omit the label so the avatar and all of its descendants remain hidden from the accessibility tree. +Provide `accessibilityLabel`, `accessibilityLabelledBy`, `aria-label`, or +`aria-labelledby` when the avatar is the identity exposed to assistive +technology. The root then uses `role="img"` and presents that name. When +adjacent text already identifies the person or entity, omit the name so the +avatar and all of its descendants remain hidden from the accessibility tree. The root honors an explicit `accessible` value, while decorative image, icon, and initials slots remain inaccessible in every content mode. `activityRing` has no exposed state or label; provide nearby text when active or collaboration status must be communicated. diff --git a/packages/agentic/components/src/components/avatar/useAvatar.ts b/packages/agentic/components/src/components/avatar/useAvatar.ts index f9dbcbb0db..a422456123 100644 --- a/packages/agentic/components/src/components/avatar/useAvatar.ts +++ b/packages/agentic/components/src/components/avatar/useAvatar.ts @@ -2,8 +2,9 @@ import * as React from 'react'; import { Image, View } from 'react-native'; import { useThemeState } from '@fluentui-react-native/design'; -import { useOptionalSlot, useSlot } from '@fluentui-react-native/framework-base'; +import { useDevWarning, useOptionalSlot, useSlot } from '@fluentui-react-native/framework-base'; +import { hasAccessibleName } from '../../common/accessibility'; import { semanticIconSources } from '../../common/iconSources'; import { Icon } from '../../primitives/icon/icon'; import { Text } from '../text/text'; @@ -45,14 +46,10 @@ export function useAvatar_unstable(props: AvatarProps): AvatarState { const hasIcon = iconProp !== null && iconProp !== undefined; const hasInitials = initialsProp !== null && initialsProp !== undefined; const contentMode = hasImage ? 'image' : hasInitials ? 'initials' : 'icon'; - const isInformative = accessibilityLabel !== undefined; + const isInformative = hasAccessibleName({ accessibilityLabel, ...rest }); const isAccessible = accessible ?? isInformative; - React.useEffect(() => { - if (__DEV__ && [hasImage, hasIcon, hasInitials].filter(Boolean).length > 1) { - console.warn('Avatar: provide only one content mode at a time.'); - } - }, [hasIcon, hasImage, hasInitials]); + useDevWarning([hasImage, hasIcon, hasInitials].filter(Boolean).length > 1, 'Avatar: provide only one content mode at a time.'); const themeState = useThemeState(); const root = useSlot(View, { @@ -60,7 +57,7 @@ export function useAvatar_unstable(props: AvatarProps): AvatarState { accessible: isAccessible, accessibilityElementsHidden: isAccessible ? accessibilityElementsHidden : true, accessibilityLabel, - accessibilityRole: 'image', + role: 'img', importantForAccessibility: isAccessible ? importantForAccessibility : 'no-hide-descendants', }); diff --git a/packages/agentic/components/src/components/badge/SPEC.md b/packages/agentic/components/src/components/badge/SPEC.md index 9f12547c7c..5dbed78a3d 100644 --- a/packages/agentic/components/src/components/badge/SPEC.md +++ b/packages/agentic/components/src/components/badge/SPEC.md @@ -33,7 +33,7 @@ Rendering order is leading icon, content, then trailing icon, omitting unavailab ## Platform behavior -Badge uses a React Native `View` and is never focusable on Windows or macOS. A badge with `accessibilityLabel` or a referenced accessible label is exposed with image role; otherwise its default root and descendants are hidden from accessibility. Icon slots are always decorative. +Badge uses a React Native `View` and is never focusable on Windows or macOS. A badge with an explicit accessible name is exposed with `role="img"`; otherwise its default root and descendants are hidden from accessibility. Icon slots are always decorative. Windows maps an informative badge to a UI Automation image; macOS maps it to an AX image. Neither platform receives press, keyboard, hover, focus, or disabled behavior from this component. Positioning is normal React Native layout unless a parent supplies placement through its own layout or a user style. diff --git a/packages/agentic/components/src/components/badge/badge.test.tsx b/packages/agentic/components/src/components/badge/badge.test.tsx index f6bd68f36c..d5fad332c1 100644 --- a/packages/agentic/components/src/components/badge/badge.test.tsx +++ b/packages/agentic/components/src/components/badge/badge.test.tsx @@ -16,7 +16,7 @@ function renderBadge(props: React.ComponentProps) { } function getRoot(component: Awaited>) { - return component.getByRole('image'); + return component.getByRole('img'); } function getRootStyle(component: Awaited>): ViewStyle { @@ -59,7 +59,7 @@ describe('Badge', () => { leadingIcon: { ...badgeIcon, testID: 'leading-icon' }, }); - expect(getRoot(component).props.accessibilityRole).toBe('image'); + expect(getRoot(component).props.role).toBe('img'); expect(getRoot(component).props.accessibilityLabel).toBe('3 unread messages'); expect(component.queryByText('Badge')).toBeNull(); expect(component.getByTestId('leading-icon')).toBeOnTheScreen(); @@ -79,7 +79,7 @@ describe('Badge', () => { const component = await renderBadge({ 'aria-label': '3 unread messages', layout: 'iconOnly' }); expect(getRoot(component).props).toMatchObject({ - accessibilityRole: 'image', + role: 'img', accessible: true, 'aria-label': '3 unread messages', }); diff --git a/packages/agentic/components/src/components/badge/badge.types.ts b/packages/agentic/components/src/components/badge/badge.types.ts index cbb2de0211..9b7512026a 100644 --- a/packages/agentic/components/src/components/badge/badge.types.ts +++ b/packages/agentic/components/src/components/badge/badge.types.ts @@ -68,7 +68,7 @@ type BadgeIconOnlyProps = BadgeCommonProps & { export type BadgeStateProps = BadgeIconAndTextProps | BadgeIconOnlyProps; -export type BadgeExposedViewProps = OwnedRootProps, 'accessibilityRole' | 'focusable'>; +export type BadgeExposedViewProps = OwnedRootProps, 'accessibilityRole' | 'focusable' | 'role'>; export type BadgeProps = BadgeStateProps & ComponentProps; diff --git a/packages/agentic/components/src/components/badge/spec/accessibility.md b/packages/agentic/components/src/components/badge/spec/accessibility.md index a759433be7..2456dfd37b 100644 --- a/packages/agentic/components/src/components/badge/spec/accessibility.md +++ b/packages/agentic/components/src/components/badge/spec/accessibility.md @@ -2,6 +2,10 @@ Badge is decorative by default: its root and children are hidden from accessibility so a host control can own the announcement. Put status or count meaning in the host's accessible name when the host already describes the badge. -For a standalone meaningful indicator, supply `accessibilityLabel` or an accessible-label reference. The root then uses React Native image role and exposes the supplied name. An icon-only badge without either name produces a development warning. Leading and trailing icons remain decorative, including on an informative badge. +For a standalone meaningful indicator, supply `accessibilityLabel`, +`accessibilityLabelledBy`, `aria-label`, or `aria-labelledby`. The root then +uses `role="img"` and exposes the supplied name. An icon-only badge without a +name produces a development warning. Leading and trailing icons remain +decorative, including on an informative badge. On Windows, an informative badge maps to a UI Automation image. On macOS, it maps to an AX image. Badge does not provide a focusable accessibility element, a disabled value, or an activation action. diff --git a/packages/agentic/components/src/components/badge/useBadge.ts b/packages/agentic/components/src/components/badge/useBadge.ts index c37f9eb5fc..277671d8dc 100644 --- a/packages/agentic/components/src/components/badge/useBadge.ts +++ b/packages/agentic/components/src/components/badge/useBadge.ts @@ -3,6 +3,7 @@ import { View } from 'react-native'; import { useThemeState } from '@fluentui-react-native/design'; import { useAccessibilityLabelWarning, useOptionalSlot, useSlot } from '@fluentui-react-native/framework-base'; +import { hasAccessibleName } from '../../common/accessibility'; import { semanticIconSources } from '../../common/iconSources'; import { Icon } from '../../primitives/icon/icon'; import { Text } from '../text/text'; @@ -46,11 +47,7 @@ export function useBadge_unstable(props: BadgeProps): BadgeState { const hasContent = !iconOnly && contentProp !== null; const hasLeadingIcon = leadingIconProp !== undefined && leadingIconProp !== null; const hasTrailingIcon = trailingIconProp !== undefined && trailingIconProp !== null; - const isInformative = - accessibilityLabel !== undefined || - rest.accessibilityLabelledBy !== undefined || - rest['aria-label'] !== undefined || - rest['aria-labelledby'] !== undefined; + const isInformative = hasAccessibleName({ accessibilityLabel, ...rest }); const isAccessible = accessible ?? isInformative; useAccessibilityLabelWarning({ @@ -67,7 +64,7 @@ export function useBadge_unstable(props: BadgeProps): BadgeState { accessible: isAccessible, accessibilityElementsHidden: isAccessible ? accessibilityElementsHidden : true, accessibilityLabel, - accessibilityRole: isInformative ? 'image' : undefined, + role: isInformative ? 'img' : undefined, importantForAccessibility: isAccessible ? importantForAccessibility : 'no-hide-descendants', focusable: false, }); diff --git a/packages/agentic/components/src/components/button/button.types.ts b/packages/agentic/components/src/components/button/button.types.ts index ebb1dc6cc2..38fca99679 100644 --- a/packages/agentic/components/src/components/button/button.types.ts +++ b/packages/agentic/components/src/components/button/button.types.ts @@ -80,7 +80,7 @@ export type ButtonStateProps = { * Props that are exposed from the underlying Pressable component at the top level. A button controls its * own children and resolves styles from tokens, so those props are exposed separately. */ -export type ButtonExposedPressableProps = OwnedRootProps>; +export type ButtonExposedPressableProps = OwnedRootProps, 'accessibilityRole' | 'role'>; /** * Props for the Button component, including state props, slot props, and exposed Pressable props. diff --git a/packages/agentic/components/src/components/button/useButton.ts b/packages/agentic/components/src/components/button/useButton.ts index 69b7a5de00..a516b0bb92 100644 --- a/packages/agentic/components/src/components/button/useButton.ts +++ b/packages/agentic/components/src/components/button/useButton.ts @@ -2,14 +2,12 @@ import type { ButtonProps, ButtonState } from './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 { disableNativeFocusRingProps, resolveFocusable } from '../../common/interaction'; +import type { NativeFocusPressableProps } from '../../common/interaction'; import { Icon } from '../../primitives/icon/icon'; import { Text } from '../text/text'; -type NativeFocusPressableProps = PressableProps & { - enableFocusRing: boolean; -}; - /** * Hook to create the state for a Button component. This is responsible for: * - resolving the prop states to their default values if unset @@ -53,6 +51,7 @@ export function useButton_unstable(props: ButtonProps): ButtonState { const themeState = useThemeState(); const nativeProps: NativeFocusPressableProps = { ...rest, + ...disableNativeFocusRingProps, role: 'button', accessibilityState: { ...accessibilityState, @@ -61,9 +60,7 @@ export function useButton_unstable(props: ButtonProps): ButtonState { }, 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, + focusable: resolveFocusable(rest.focusable, disabled), }; const [pressableProps, pressableState] = usePressableState(nativeProps); const root = useSlot(Pressable, { ...pressableProps, ref: rootRef }); diff --git a/packages/agentic/components/src/components/card/card.types.ts b/packages/agentic/components/src/components/card/card.types.ts index 01d44494d9..6b1930c603 100644 --- a/packages/agentic/components/src/components/card/card.types.ts +++ b/packages/agentic/components/src/components/card/card.types.ts @@ -41,7 +41,7 @@ export type CardStateProps = { selected?: boolean; }; -export type CardExposedPressableProps = OwnedRootProps & { +export type CardExposedPressableProps = OwnedRootProps & { children?: never; } & Pick, 'ref'>; diff --git a/packages/agentic/components/src/components/card/useCard.ts b/packages/agentic/components/src/components/card/useCard.ts index 171ceeadd8..453b1ca21d 100644 --- a/packages/agentic/components/src/components/card/useCard.ts +++ b/packages/agentic/components/src/components/card/useCard.ts @@ -4,6 +4,7 @@ import type { PressableProps } from 'react-native'; import { type PropsWithRefOf, usePressableState, useOptionalSlot, useSlot } from '@fluentui-react-native/framework-base'; import { useThemeState } from '@fluentui-react-native/design'; +import { resolveFocusable } from '../../common/interaction'; import type { CardProps, CardState } from './card.types'; const horizontalCollapseWidth = 480; @@ -60,7 +61,7 @@ export function useCard_unstable(props: CardProps): CardState { accessibilityHint, accessibilityLabel, accessibilityLabelledBy, - accessibilityRole: 'button', + role: 'button', accessibilityState: { ...accessibilityState, disabled, @@ -71,7 +72,7 @@ export function useCard_unstable(props: CardProps): CardState { delayLongPress, delayPressIn, disabled: !isInteractive || disabled, - focusable: isInteractive && !disabled && (focusable ?? true), + focusable: isInteractive && resolveFocusable(focusable, disabled), onBlur, onFocus, onHoverIn, @@ -97,7 +98,7 @@ export function useCard_unstable(props: CardProps): CardState { disabled, }, accessible: accessible ?? false, - accessibilityRole: (accessible ?? false) ? 'group' : undefined, + role: (accessible ?? false) ? 'group' : undefined, focusable: false, ref: rootRef, testID, diff --git a/packages/agentic/components/src/components/checkbox/SPEC.md b/packages/agentic/components/src/components/checkbox/SPEC.md index d77c99b01b..de5252872a 100644 --- a/packages/agentic/components/src/components/checkbox/SPEC.md +++ b/packages/agentic/components/src/components/checkbox/SPEC.md @@ -42,8 +42,8 @@ each Checkbox itself. The root also accepts the owned `PressableProps` surface. `children` is typed `never`; Checkbox owns its subtree. A caller `style` is applied after the -token-derived root styles. The broad root type accepts `accessibilityRole` and -caller checked or disabled accessibility state, but the implementation writes +token-derived root styles. The root type omits `role` and the legacy +`accessibilityRole`. Caller checked or disabled accessibility state is accepted, but the implementation writes its resolved role and state afterward, so those caller values are ignored. ### Slots and anatomy diff --git a/packages/agentic/components/src/components/checkbox/checkbox.test.tsx b/packages/agentic/components/src/components/checkbox/checkbox.test.tsx index 1d441895c2..bdcc4f934e 100644 --- a/packages/agentic/components/src/components/checkbox/checkbox.test.tsx +++ b/packages/agentic/components/src/components/checkbox/checkbox.test.tsx @@ -31,7 +31,7 @@ describe('Checkbox', () => { const component = await renderCheckbox({ label: 'Save drafts' }); const root = getRoot(component); - expect(root.props.accessibilityRole).toBe('checkbox'); + expect(root.props.role).toBe('checkbox'); expect(root.props.accessibilityState).toEqual({ checked: false, disabled: false }); expect(root.props.accessibilityLabel).toBe('Save drafts'); expect(component.getByText('Save drafts')).toBeOnTheScreen(); diff --git a/packages/agentic/components/src/components/checkbox/checkbox.types.ts b/packages/agentic/components/src/components/checkbox/checkbox.types.ts index 89f474bb0d..14ea5f6e4f 100644 --- a/packages/agentic/components/src/components/checkbox/checkbox.types.ts +++ b/packages/agentic/components/src/components/checkbox/checkbox.types.ts @@ -52,7 +52,7 @@ export type CheckboxStateProps = { */ export type CheckboxStatusDriverKeys = 'defaultStatus' | 'onStatusChange'; -export type CheckboxRootProps = OwnedRootProps> & { +export type CheckboxRootProps = OwnedRootProps, 'accessibilityRole' | 'role'> & { children?: never; }; diff --git a/packages/agentic/components/src/components/checkbox/spec/accessibility.md b/packages/agentic/components/src/components/checkbox/spec/accessibility.md index c442876ba3..ef4cdb0403 100644 --- a/packages/agentic/components/src/components/checkbox/spec/accessibility.md +++ b/packages/agentic/components/src/components/checkbox/spec/accessibility.md @@ -3,7 +3,7 @@ ## Native semantics The root is a single accessible React Native element with -`accessibilityRole="checkbox"`. It defaults to `accessible={true}` and is +`role="checkbox"`. It defaults to `accessible={true}` and is focusable unless disabled. `accessibilityState.checked` carries the status directly: `false` for diff --git a/packages/agentic/components/src/components/checkbox/useCheckbox.ts b/packages/agentic/components/src/components/checkbox/useCheckbox.ts index b43520d695..7cd6c16158 100644 --- a/packages/agentic/components/src/components/checkbox/useCheckbox.ts +++ b/packages/agentic/components/src/components/checkbox/useCheckbox.ts @@ -2,16 +2,21 @@ import * as React from 'react'; import { Pressable } from 'react-native'; import type { StyleProp, ViewStyle } from 'react-native'; -import { useControllableValue, useFocusVisible, usePressableState, useOptionalSlot, useSlot } from '@fluentui-react-native/framework-base'; +import { + useControllableValue, + useDevWarning, + useFocusVisible, + usePressableState, + useOptionalSlot, + useSlot, +} from '@fluentui-react-native/framework-base'; import { useThemeState } from '@fluentui-react-native/design'; +import { disableNativeFocusRingProps, resolveFocusable } from '../../common/interaction'; +import type { NativeFocusPressableProps } from '../../common/interaction'; import { Text } from '../text/text'; import type { CheckboxProps, CheckboxState, CheckboxStatus } from './checkbox.types'; -type NativeFocusPressableProps = React.ComponentProps & { - enableFocusRing: boolean; -}; - function getNextStatus(status: CheckboxStatus): CheckboxStatus { return status === 'checked' ? 'unchecked' : 'checked'; } @@ -45,11 +50,7 @@ export function useCheckbox_unstable(props: CheckboxProps): CheckboxState { const renderSecondaryText = showLabel && showSecondaryText; const themeState = useThemeState(); - React.useEffect(() => { - if (__DEV__ && showSecondaryText && !showLabel) { - console.warn('Checkbox: secondary text requires a visible label.'); - } - }, [showLabel, showSecondaryText]); + useDevWarning(showSecondaryText && !showLabel, 'Checkbox: secondary text requires a visible label.'); const handlePress = React.useCallback( (event: Parameters>[0]) => { @@ -67,9 +68,10 @@ export function useCheckbox_unstable(props: CheckboxProps): CheckboxState { const nativeProps: NativeFocusPressableProps = { ...rest, + ...disableNativeFocusRingProps, accessibilityHint: rootAccessibilityHint, accessibilityLabel: rootAccessibilityLabel, - accessibilityRole: 'checkbox', + role: 'checkbox', accessibilityState: { ...accessibilityState, checked: status === 'indeterminate' ? 'mixed' : status === 'checked', @@ -77,8 +79,7 @@ export function useCheckbox_unstable(props: CheckboxProps): CheckboxState { }, accessible: rest.accessible ?? true, disabled, - enableFocusRing: false, - focusable: rest.focusable ?? !disabled, + focusable: resolveFocusable(rest.focusable, disabled), onPress: handlePress, }; const [focusVisibleProps, focusVisible] = useFocusVisible(nativeProps); 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..e92aadb009 --- /dev/null +++ b/packages/agentic/components/src/components/destructive-button/destructive-button.test.tsx @@ -0,0 +1,337 @@ +/** @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('keeps a disabled button out of the tab order when focusable is requested', async () => { + const component = await renderDestructiveButton({ content: 'Unavailable', disabled: true, focusable: true }); + + expect(getRoot(component).props.focusable).toBe(false); + }); + + 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.each(['', ' ', { children: null }])('treats empty content as icon-only content', async (content) => { + const warn = jest.spyOn(console, 'warn').mockImplementation(); + const component = await renderDestructiveButton({ + content, + icon: { imageSource: { uri: 'delete.png' } }, + }); + + expect(getRootStyle(component).borderRadius).toBe(9999); + 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('removes caller-supplied selection semantics while retaining other accessibility state', async () => { + const component = await renderDestructiveButton({ + 'aria-checked': true, + 'aria-selected': true, + accessibilityState: { busy: true, checked: true, selected: true }, + content: 'Delete', + }); + const root = getRoot(component); + + expect(root.props['aria-checked']).toBeUndefined(); + expect(root.props['aria-selected']).toBeUndefined(); + expect(root.props.accessibilityState).toEqual({ busy: true, disabled: false }); + }); + + 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..e1fbe15f2f --- /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, 'accessibilityRole' | 'role'>; + +/** + * 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..e558587c83 --- /dev/null +++ b/packages/agentic/components/src/components/destructive-button/useDestructiveButton.ts @@ -0,0 +1,92 @@ +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 { disableNativeFocusRingProps, resolveFocusable } from '../../common/interaction'; +import type { NativeFocusPressableProps } from '../../common/interaction'; +import { Icon } from '../../primitives/icon/icon'; +import { Text } from '../text/text'; + +function hasVisibleContent(content: DestructiveButtonProps['content']): boolean { + if (content === undefined || content === null || (typeof content === 'string' && content.trim().length === 0)) { + return false; + } + if (typeof content === 'object' && !Array.isArray(content) && 'children' in content) { + const { children } = content as { children?: unknown }; + return !(children === undefined || children === null || (typeof children === 'string' && children.trim().length === 0)); + } + return true; +} + +/** + * 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 { + 'aria-checked': _ariaChecked, + 'aria-selected': _ariaSelected, + accessibilityState, + appearance = 'primary', + content: contentProp, + disabled = false, + icon: iconProp, + iconPosition = 'before', + ref: rootRef, + shape, + size = 'medium', + style: userStyle, + ...rest + } = props; + const hasContent = hasVisibleContent(contentProp); + const hasIcon = iconProp !== undefined && iconProp !== null; + const iconOnly = !hasContent && hasIcon; + const resolvedAccessibilityState = { ...accessibilityState }; + delete resolvedAccessibilityState.checked; + delete resolvedAccessibilityState.selected; + + 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, + ...disableNativeFocusRingProps, + role: 'button', + accessibilityState: { + ...resolvedAccessibilityState, + disabled, + }, + accessible: rest.accessible ?? true, + disabled, + focusable: resolveFocusable(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/components/divider/SPEC.md b/packages/agentic/components/src/components/divider/SPEC.md index c8f7274035..a4b7309202 100644 --- a/packages/agentic/components/src/components/divider/SPEC.md +++ b/packages/agentic/components/src/components/divider/SPEC.md @@ -33,8 +33,8 @@ stretches along its parent's primary axis and does not define its own extent. | `label` | slot for `Text` | renders the text `Text` | The label content. `null` removes the label. | | `icon` | slot for `Icon` | absent | A leading icon inside the content container. `null` or omission removes it. | -The root accepts the owned `ViewProps` surface except `accessibilityRole` and -`focusable`, which the component owns. A caller `style` is applied after the +The root accepts the owned `ViewProps` surface except `role`, the legacy +`accessibilityRole`, and `focusable`, which the component owns. A caller `style` is applied after the token-derived root styles. `label` and `icon` are slots: they accept shorthand children, a props object, diff --git a/packages/agentic/components/src/components/divider/divider.test.tsx b/packages/agentic/components/src/components/divider/divider.test.tsx index f2e384f4f9..0256caf07f 100644 --- a/packages/agentic/components/src/components/divider/divider.test.tsx +++ b/packages/agentic/components/src/components/divider/divider.test.tsx @@ -24,7 +24,7 @@ describe('Divider', () => { const component = await renderDivider({}); const root = getRoot(component); - expect(root.props.accessibilityRole).toBe('separator'); + expect(root.props.role).toBe('separator'); expect(root.props.accessibilityLabel).toBe('Text'); expect(root.props.focusable).toBe(false); expect(component.getByText('Text')).toBeOnTheScreen(); diff --git a/packages/agentic/components/src/components/divider/divider.types.ts b/packages/agentic/components/src/components/divider/divider.types.ts index 6505f3d350..e281f3afbd 100644 --- a/packages/agentic/components/src/components/divider/divider.types.ts +++ b/packages/agentic/components/src/components/divider/divider.types.ts @@ -30,7 +30,7 @@ export type DividerStateProps = { vertical?: boolean; }; -export type DividerExposedViewProps = OwnedRootProps, 'accessibilityRole' | 'focusable'>; +export type DividerExposedViewProps = OwnedRootProps, 'accessibilityRole' | 'focusable' | 'role'>; export type DividerProps = DividerStateProps & ComponentProps; diff --git a/packages/agentic/components/src/components/divider/spec/accessibility.md b/packages/agentic/components/src/components/divider/spec/accessibility.md index 0c63c3d124..40f8c3b8f5 100644 --- a/packages/agentic/components/src/components/divider/spec/accessibility.md +++ b/packages/agentic/components/src/components/divider/spec/accessibility.md @@ -2,7 +2,7 @@ ## Native semantics -The root is a `View` with `accessibilityRole="separator"` and defaults to +The root is a `View` with `role="separator"` and defaults to `accessible={true}`. The role is owned by the component and is not part of the public root prop surface. diff --git a/packages/agentic/components/src/components/divider/useDivider.ts b/packages/agentic/components/src/components/divider/useDivider.ts index a788747f4f..8f16e1bd78 100644 --- a/packages/agentic/components/src/components/divider/useDivider.ts +++ b/packages/agentic/components/src/components/divider/useDivider.ts @@ -35,7 +35,7 @@ export function useDivider_unstable(props: DividerProps): DividerState { const root = useSlot(View, { ...rest, accessibilityLabel: accessibilityLabel ?? labelText, - accessibilityRole: 'separator', + role: 'separator', accessible: rest.accessible ?? true, focusable: false, }); diff --git a/packages/agentic/components/src/components/input/SPEC.md b/packages/agentic/components/src/components/input/SPEC.md index 2e9c8a58f8..32b6cc5c60 100644 --- a/packages/agentic/components/src/components/input/SPEC.md +++ b/packages/agentic/components/src/components/input/SPEC.md @@ -83,14 +83,18 @@ group, then the underline when the underline variant is active. read-only, and invalid state, keep every wrapper view non-accessible, and keep read-only fields editable-in-name-only rather than disabled. - **INP-006:** Resolve size metrics, stroke colors, icon size and color, and - placeholder color from theme tokens, and apply the caller `style` last. + placeholder color from theme tokens, keep the single-line text vertically + centered across value and focus transitions, and apply the caller `style` + last. ## Platform behavior Windows and macOS use the platform text input for caret placement, selection, text composition, clipboard behavior, and character entry. Input adds no key handling of its own and does not intercept Tab, so the platform tab order and -the platform text-editing shortcuts apply unchanged. +the platform text-editing shortcuts apply unchanged. Input explicitly centers +the native single-line text so clearing a focused value does not move its +placeholder toward the top edge before blur. Hover and press state are tracked from events on the text input rather than the surrounding container, so pointer feedback follows the text area. Disabling the diff --git a/packages/agentic/components/src/components/input/input.styles.ts b/packages/agentic/components/src/components/input/input.styles.ts index 87a8787ecb..6925d31bc4 100644 --- a/packages/agentic/components/src/components/input/input.styles.ts +++ b/packages/agentic/components/src/components/input/input.styles.ts @@ -3,7 +3,7 @@ import type { ColorValue, TextStyle, ViewStyle } from 'react-native'; import type { FlexTokens } from '@fluentui-react-native/design'; import type { ThemeState } from '@fluentui-react-native/design'; -import { getNumericStyleValue, getThemedStateStyleFactory } from '@fluentui-react-native/design/styling'; +import { getNumericStyleValueAsNumber as toNumber, getThemedStateStyleFactory } from '@fluentui-react-native/design/styling'; import type { StyleDefinition } from '@fluentui-react-native/design/styling'; import { cornerRadiusNone, size160, size200, size240 } from '@fluentui-react-native/design/tokens/global'; @@ -43,6 +43,7 @@ export const inputStyles = StyleSheet.create({ flexShrink: 1, minWidth: 0, paddingHorizontal: 0, + textAlignVertical: 'center', }, underline: { left: 0, @@ -72,8 +73,6 @@ type ContentStateLevels = typeof contentStateLevels; type TextStateLevels = typeof textStateLevels; type VisualStateLevels = typeof visualStateLevels; -const toNumber = (value: unknown): number => Number(getNumericStyleValue(value)); - function getMetrics(tokens: FlexTokens, size: InputSize) { const { fontSize, lineHeight, spacing } = tokens; const metrics = { diff --git a/packages/agentic/components/src/components/input/input.test.tsx b/packages/agentic/components/src/components/input/input.test.tsx index ead8c393af..23b20a3d1a 100644 --- a/packages/agentic/components/src/components/input/input.test.tsx +++ b/packages/agentic/components/src/components/input/input.test.tsx @@ -9,7 +9,14 @@ import { defaultFlexTokens } from '@fluentui-react-native/design/testing'; import { Input } from './input'; function getTextbox(component: Awaited>) { - return component.getByRole('textbox'); + if (!component.root) { + throw new Error('Input did not render a root instance.'); + } + const [textInput] = component.root.queryAll((instance) => instance.type === 'TextInput'); + if (!textInput) { + throw new Error('Input did not render a native TextInput.'); + } + return textInput; } function getContents(component: Awaited>) { @@ -33,7 +40,7 @@ describe('Input', () => { const component = await render(); const textbox = getTextbox(component); - expect(textbox.props.accessibilityRole).toBe('textbox'); + expect(textbox.props.role).toBeUndefined(); expect(textbox.props.accessibilityState).toEqual({ disabled: false, invalid: undefined, readOnly: false }); expect(getContents(component).props.testID).toBe('input-contents'); expect(flattenStyle(getContents(component).props.style)).toMatchObject({ @@ -41,6 +48,9 @@ describe('Input', () => { borderWidth: 1, minHeight: 32, }); + expect(flattenStyle(textbox.props.style)).toMatchObject({ + textAlignVertical: 'center', + }); }); it('forwards change and interaction handlers while updating visual state', async () => { @@ -151,7 +161,7 @@ describe('Input', () => { for (const { fontSize, lineHeight, minHeight, size } of cases) { const component = await render(); - expect(flattenStyle(component.getByRole('textbox').props.style)).toMatchObject({ + expect(flattenStyle(getTextbox(component).props.style)).toMatchObject({ fontSize, lineHeight, }); diff --git a/packages/agentic/components/src/components/input/input.types.ts b/packages/agentic/components/src/components/input/input.types.ts index ec3ccade48..a2fae931a4 100644 --- a/packages/agentic/components/src/components/input/input.types.ts +++ b/packages/agentic/components/src/components/input/input.types.ts @@ -49,7 +49,7 @@ export type InputStateProps = { onPressOut?: (...args: any[]) => void; }; -export type InputExposedRootProps = OwnedRootProps>; +export type InputExposedRootProps = OwnedRootProps, 'accessibilityRole' | 'role'>; export type InputProps = InputStateProps & ComponentProps; diff --git a/packages/agentic/components/src/components/input/spec/accessibility.md b/packages/agentic/components/src/components/input/spec/accessibility.md index 47b7527ef9..66d2c3cb44 100644 --- a/packages/agentic/components/src/components/input/spec/accessibility.md +++ b/packages/agentic/components/src/components/input/spec/accessibility.md @@ -2,12 +2,15 @@ ## Native semantics -The text input is the accessible element. It carries -`accessibilityRole="textbox"` and defaults to `accessible={true}`. The root, the +The native `TextInput` is the accessible element and supplies its intrinsic +textbox semantics. It defaults to `accessible={true}`. The root, the contents row, the icon-and-text stack, the trailing icon group, the underline view, and all three icon slots are marked `accessible={false}`, so the field is announced once rather than as a stack of nested containers. +SearchBox specializes the text input with the ARIA-aligned +`role="searchbox"`. + `accessibilityLabel`, `accessibilityHint`, `accessibilityState`, `accessible`, `focusable`, and `testID` are accepted on the root and applied to the text input, so callers do not have to reach into the `textInput` slot to name or diff --git a/packages/agentic/components/src/components/input/spec/tokens.yaml b/packages/agentic/components/src/components/input/spec/tokens.yaml index 9a7508de41..05f6fa9135 100644 --- a/packages/agentic/components/src/components/input/spec/tokens.yaml +++ b/packages/agentic/components/src/components/input/spec/tokens.yaml @@ -98,6 +98,7 @@ bindings: flexShrink: 1 minWidth: 0 paddingHorizontal: 0 + textAlignVertical: center typography: fontFamily: fontFamily.functional fontSize: diff --git a/packages/agentic/components/src/components/input/useInput.ts b/packages/agentic/components/src/components/input/useInput.ts index 4bf3b5345c..3414d0971c 100644 --- a/packages/agentic/components/src/components/input/useInput.ts +++ b/packages/agentic/components/src/components/input/useInput.ts @@ -3,8 +3,9 @@ import { TextInput, View } from 'react-native'; import type { TextInputProps } from 'react-native'; import { useThemeState } from '@fluentui-react-native/design'; -import { useControllableValue, useOptionalSlot, useSlot } from '@fluentui-react-native/framework-base'; +import { useControllableValue, useDevWarning, useOptionalSlot, useSlot } from '@fluentui-react-native/framework-base'; +import { resolveFocusable } from '../../common/interaction'; import { Icon } from '../../primitives/icon/icon'; import { getInputResolvedStyles } from './input.styles'; @@ -93,11 +94,7 @@ export function useInput_unstable(props: InputProps): InputState { } }, [disabled]); - React.useEffect(() => { - if (__DEV__ && iconEnd2Prop && !iconEnd1Prop) { - console.warn('Input: iconEnd2 requires iconEnd1 to be provided.'); - } - }, [iconEnd1Prop, iconEnd2Prop]); + useDevWarning(Boolean(iconEnd2Prop && !iconEnd1Prop), 'Input: iconEnd2 requires iconEnd1 to be provided.'); const visualState = getVisualState({ disabled, error, focused, hovered, pressed, readOnly }); const resolvedStyles = getInputResolvedStyles({ ...themeState, size, variant, visualState }); @@ -151,7 +148,6 @@ export function useInput_unstable(props: InputProps): InputState { ...slotProps, accessibilityHint: slotProps.accessibilityHint ?? accessibilityHint, accessibilityLabel: slotProps.accessibilityLabel ?? accessibilityLabel, - accessibilityRole: slotProps.accessibilityRole ?? 'textbox', accessibilityState: { ...slotProps.accessibilityState, ...accessibilityState, @@ -161,7 +157,7 @@ export function useInput_unstable(props: InputProps): InputState { } as InputAccessibilityState, accessible: slotProps.accessible ?? accessible ?? true, editable: disabled || readOnly ? false : (slotProps.editable ?? true), - focusable: slotProps.focusable ?? focusable ?? !disabled, + focusable: resolveFocusable(slotProps.focusable ?? focusable, disabled), onBlur: mergeHandlers(slotProps.onBlur, onBlur, handleBlur), onChangeText: mergeHandlers(slotProps.onChangeText, onChangeText, handleChangeText), onFocus: mergeHandlers(slotProps.onFocus, onFocus, handleFocus), diff --git a/packages/agentic/components/src/components/interaction-tag/SPEC.md b/packages/agentic/components/src/components/interaction-tag/SPEC.md new file mode 100644 index 0000000000..c274c5c7d5 --- /dev/null +++ b/packages/agentic/components/src/components/interaction-tag/SPEC.md @@ -0,0 +1,180 @@ +--- +name: interaction-tag +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 +--- + +# InteractionTag + +## Scope + +InteractionTag is a compact labeling element that represents a person, a +category, or a keyword, and offers two separate actions on it: a primary action +that inspects or opens the thing the tag names, and a secondary action that +removes the tag. + +It renders a non-interactive container holding two sibling `Pressable` regions +separated by a hairline divider. The leading region carries optional leading +media and the label; the trailing region carries the dismiss glyph. Each region +is its own hit target, its own tab stop, and its own hover, press, and focus +state. + +This is the difference from [Tag](../tag/SPEC.md). A tag is one target whose +only action is removal, so the dismiss glyph there is decorative. An interaction +tag has two targets, so the dismiss glyph is a real control with its own name. + +InteractionTag has no selected or checked state, is not a toggle, and owns no +list or group behavior. It never removes itself: dismissal is the caller +dropping it from its own data. + +## Public contract + +### Props and defaults + +| Prop | Type | Default | Contract | +| ------------ | ------------------------- | ------------- | ------------------------------------------------------------------------------------------------- | +| `appearance` | `primary \| secondary` | `secondary` | Selects the background, foreground, and divider color family for both regions. | +| `layout` | `iconAndText \| iconOnly` | `iconAndText` | `iconOnly` suppresses the label and forces the circular radius. | +| `size` | `small \| medium` | `medium` | Selects action padding, text style, and the leading, avatar, and dismiss glyph sizes. | +| `shape` | `rounded \| circular` | `rounded` | Selects the container corner radius in the icon-and-text layout. Ignored in the icon-only layout. | +| `disabled` | `boolean` | `false` | Blocks both regions, removes both from the tab order, and selects the disabled colors. | +| `style` | `StyleProp` | none | Applied to the container after the resolved container styles. | + +The container root is a `View`, so the remaining public props are the owned +`View` props, including `ref` and `testID`. The container has no press handling +of its own. + +**ITAG-001:** Resolve `appearance`, `layout`, `size`, `shape`, and `disabled` to +the documented defaults and expose the remaining owned `View` props on the +container root. + +### Slots and anatomy + +| Slot | Type | Rendered | Contract | +| --------------- | ----------- | --------------------------- | ---------------------------------------------------------------------------------------------------- | +| `root` | `View` | always | The layout container. Not interactive and not an accessibility element. | +| `primaryAction` | `Pressable` | always | The leading hit target. Carries `onPress`, its own `ref`, and its own accessible name. | +| `avatar` | `Avatar` | only when supplied | Leading media for a person or entity. Mutually exclusive with `leadingIcon`. | +| `leadingIcon` | `Icon` | only when supplied | Leading glyph for a category. Mutually exclusive with `avatar`. | +| `content` | `Text` | by default in `iconAndText` | Defaults to the text `Tag text`; pass `null` to suppress it; never rendered in the icon-only layout. | +| `dismiss` | `Pressable` | always | The trailing hit target. Carries `onPress`, its own `ref`, and its required accessible name. | +| `dismissIcon` | `Icon` | by default | Defaults to the shared dismiss glyph; pass `null` to replace it with other dismiss content. | + +`primaryAction` and `dismiss` accept props objects only, not shorthand children, +because the component owns the children of both regions. The divider between +them is structural and is not public API. + +**ITAG-002:** Present two independently focusable button regions inside one +non-interactive container. Each region owns its own press, hover, and focus +state and forwards its own handlers, so neither region reacts to the other. + +**ITAG-003:** Render the primary action, then the divider, then the dismiss +action. Inside the primary action render the focus visual, the leading media, +and the content in that order; inside the dismiss action render the focus visual +and the dismiss glyph in that order. + +**ITAG-004:** Accept a leading icon or an avatar, never both. The public props +reject the pair at compile time; when both arrive at runtime the avatar is +rendered, the icon is dropped, and development builds warn. + +**ITAG-005:** Render the label by default with the placeholder text, drop it in +the icon-only layout, and render leading media only when it is supplied. + +### State ownership + +InteractionTag owns hover, press, and focus separately for each region. It has +no selection, no internal dismissed state, and no lifecycle. A single `disabled` +prop governs both regions together, because the two actions belong to one tag. + +**ITAG-006:** Resolve each region's background from `appearance` together with +that region's own disabled, pressed, and hovered state, so hovering or pressing +one region never changes the other. + +**ITAG-007:** Resolve the foreground from `appearance` and `disabled` only, and +share it between both regions, so the label and both glyphs hold their color +across rest, hover, and pressed. + +**ITAG-008:** Resolve the container corner radius from `size`, `layout`, and +`shape` together, give each action that radius on its outer edge only, keep both +actions square against the divider, and use the circular radius for every +icon-only tag regardless of `shape`. + +**ITAG-009:** Draw the divider as a full-bleed hairline between the two regions +whose color follows `appearance` and `disabled`, and keep it out of the +accessibility tree and out of hit testing. + +**ITAG-010:** Size the leading glyph, the avatar, and the dismiss glyph from +`size`, keeping the dismiss glyph one step smaller than the leading glyph, and +keep each action at or above the twenty-four pixel minimum target box. + +**ITAG-011:** Expose each region as a button with the disabled state merged over +caller-supplied state, keep both regions out of the tab order while disabled, +require an accessible name on the dismiss action, and require one on the primary +action in the icon-only layout. + +**ITAG-012:** Show a two-ring focus visual inside a region while that region is +focused and not disabled, following that region's resolved outer corner radii. + +**ITAG-013:** Forward the top-level `ref` to the container and each action +slot's own `ref` to that region's `Pressable`. + +## Platform behavior + +Windows and macOS behave identically. Tab moves through the primary action and +then the dismiss action, so one enabled interaction tag is two tab stops. Both +regions leave the tab order while disabled. Enter and Space activate the focused +region through the shared pressable behavior and produce the same `onPress` a +pointer press produces. + +The two regions are siblings inside a plain container `View`, not nested +pressables. Nothing bubbles between them, so pressing the dismiss action never +also runs the primary action, and there is no press-responder competition to +resolve. The divider and both focus visuals are excluded from hit testing, so +the only two touchable surfaces on Windows Fabric are the two action regions +themselves. + +Both focus visuals stay mounted and change only opacity, which keeps React +Native Windows Fabric from creating border visuals after mount. Neither region +uses the native focus ring or React Native `outline*` props. + +Each action takes its outer corner radius from the logical corner properties, so +a right-to-left surface rounds the trailing region on the correct side without a +separate style. + +On Windows each region maps to a UI Automation button that exposes its disabled +state; Narrator reads two controls per tag. On macOS each maps to the equivalent +button element for VoiceOver. The leading media, the divider, and the dismiss +glyph contribute nothing to either name. + +## Divergences from Flex + +| ID | Disposition | React Native contract | Follow-up | +| ------------------------------------------ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------- | +| `interaction-tag-appearance-prop-name` | Accepted | The color family axis is named `appearance` with the values `primary` and `secondary`, matching the other components in this package. Flex names the axis differently while using the same two values. | None. | +| `interaction-tag-leading-content-is-slots` | Accepted | Leading media is two mutually exclusive optional slots that render only when supplied. Flex models it as two boolean toggles with the icon toggle on by default. | None. A slot that renders a caller-supplied element cannot default to on. | +| `interaction-tag-dismiss-is-caller-owned` | Accepted | The dismiss action reports `onPress`; the caller removes the tag from its own data. Flex describes the secondary action as removing the tag. | None. Removing itself is not something a controlled React Native component can do. | +| `interaction-tag-single-disabled-axis` | Accepted | One `disabled` prop governs both regions. Flex documents a single State axis for the whole tag and does not describe disabling one region alone. | Revisit only if a scenario needs a live primary action beside a locked dismiss. | +| `interaction-tag-no-state-transition` | Accepted | Hover, press, and disabled color changes are applied immediately with no transition. Flex calls for a short eased color transition. | Needs motion tokens and an animated color layer on each region before it can be adopted. | + +## Conformance + +| Requirement | Evidence | +| ----------- | --------------------------------------------------------------------------------------------------------------- | +| ITAG-001 | `interaction-tag.types.ts`, `useInteractionTag.ts`, `interaction-tag.types.test.ts`, `interaction-tag.test.tsx` | +| ITAG-002 | `useInteractionTag.ts`, `renderInteractionTag.tsx`, `interaction-tag.test.tsx`, `interaction-tag.stories.tsx` | +| ITAG-003 | `renderInteractionTag.tsx`, `interaction-tag.test.tsx` | +| ITAG-004 | `interaction-tag.types.ts`, `useInteractionTag.ts`, `interaction-tag.types.test.ts`, `interaction-tag.test.tsx` | +| ITAG-005 | `useInteractionTag.ts`, `interaction-tag.test.tsx` | +| ITAG-006 | `interaction-tag.styles.ts`, `useInteractionTagStyles.ts`, `interaction-tag.test.tsx` | +| ITAG-007 | `interaction-tag.styles.ts`, `useInteractionTagStyles.ts`, `interaction-tag.test.tsx` | +| ITAG-008 | `interaction-tag.styles.ts`, `interaction-tag.test.tsx`, `interaction-tag.stories.tsx` | +| ITAG-009 | `interaction-tag.styles.ts`, `useInteractionTag.ts`, `useInteractionTagStyles.ts`, `interaction-tag.test.tsx` | +| ITAG-010 | `interaction-tag.styles.ts`, `useInteractionTagStyles.ts`, `interaction-tag.test.tsx` | +| ITAG-011 | `useInteractionTag.ts`, `interaction-tag.test.tsx` | +| ITAG-012 | `useInteractionTagStyles.ts`, `interaction-tag.test.tsx`, `interaction-tag.stories.tsx` | +| ITAG-013 | `useInteractionTag.ts`, `interaction-tag.types.test.ts`, `interaction-tag.test.tsx` | diff --git a/packages/agentic/components/src/components/interaction-tag/interaction-tag.stories.tsx b/packages/agentic/components/src/components/interaction-tag/interaction-tag.stories.tsx new file mode 100644 index 0000000000..a4a2ed02ad --- /dev/null +++ b/packages/agentic/components/src/components/interaction-tag/interaction-tag.stories.tsx @@ -0,0 +1,352 @@ +/** @jsxImportSource @fluentui-react-native/framework-base */ +import { useState } from 'react'; +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 { InteractionTag } from './interaction-tag'; +import type { InteractionTagAppearance, InteractionTagLayout, InteractionTagShape, InteractionTagSize } from './interaction-tag.types'; + +type StoryGroupProps = { + children: ReactNode; + label: string; +}; + +const StoryGroup = ({ children, label }: StoryGroupProps) => ( + + {label} + {children} + +); + +const appearances: readonly { label: string; value: InteractionTagAppearance }[] = [ + { label: 'Primary', value: 'primary' }, + { label: 'Secondary', value: 'secondary' }, +]; + +const layouts: readonly { label: string; value: InteractionTagLayout }[] = [ + { label: 'Icon and text', value: 'iconAndText' }, + { label: 'Icon only', value: 'iconOnly' }, +]; + +const sizes: readonly { label: string; value: InteractionTagSize }[] = [ + { label: 'Small', value: 'small' }, + { label: 'Medium', value: 'medium' }, +]; + +const shapes: readonly { label: string; value: InteractionTagShape }[] = [ + { label: 'Rounded', value: 'rounded' }, + { label: 'Circular', value: 'circular' }, +]; + +const leadingIcon = { fontSource: { codepoint: 0x2605, fontFamily: 'Arial' } } as const; +const dismissIcon = { fontSource: { codepoint: 0x2715, fontFamily: 'Arial' } } as const; + +const dismiss = { accessibilityLabel: 'Remove Engineering' } as const; + +const meta: Meta = { + title: 'Components/InteractionTag', + component: InteractionTag, + args: { + appearance: 'secondary', + content: 'Engineering', + dismiss: { accessibilityLabel: 'Remove Engineering', testID: 'agentic-storybook-interaction-tag-dismiss' }, + layout: 'iconAndText', + primaryAction: { testID: 'agentic-storybook-interaction-tag-primary' }, + shape: 'rounded', + size: 'medium', + testID: 'agentic-storybook-interaction-tag', + }, + argTypes: { + appearance: { control: 'select', options: appearances.map(({ value }) => value) }, + disabled: { control: 'boolean' }, + layout: { control: 'select', options: layouts.map(({ value }) => value) }, + shape: { control: 'select', options: shapes.map(({ value }) => value) }, + size: { control: 'select', options: sizes.map(({ value }) => value) }, + }, + parameters: { + docs: { + description: { + component: + 'InteractionTag is a tag with two separate actions: a primary action that opens the thing the tag names, and a dismiss action that removes it. Each action is its own hit target and its own tab stop.', + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + tags: ['desktop-e2e'], + parameters: { + desktopDriver: { + version: 1, + tests: [ + { + id: 'independent-regions', + title: 'Exposes two independently focusable button regions', + requires: ['element-screenshot', 'focus'], + steps: [ + { action: 'wait', target: { testId: 'agentic-storybook-interaction-tag' } }, + { expect: { state: 'role', target: { testId: 'agentic-storybook-interaction-tag-primary' }, value: 'button' } }, + { expect: { state: 'role', target: { testId: 'agentic-storybook-interaction-tag-dismiss' }, value: 'button' } }, + { expect: { state: 'enabled', target: { testId: 'agentic-storybook-interaction-tag-primary' }, value: true } }, + { expect: { state: 'enabled', target: { testId: 'agentic-storybook-interaction-tag-dismiss' }, value: true } }, + { action: 'click', target: { testId: 'agentic-storybook-interaction-tag-primary' } }, + { expect: { state: 'focused', target: { testId: 'agentic-storybook-interaction-tag-primary' }, value: true } }, + { expect: { state: 'focused', target: { testId: 'agentic-storybook-interaction-tag-dismiss' }, value: false } }, + { + action: 'screenshot', + name: 'interaction-tag-primary-focused', + target: { testId: 'agentic-storybook-interaction-tag' }, + }, + { action: 'click', target: { testId: 'agentic-storybook-interaction-tag-dismiss' } }, + { expect: { state: 'focused', target: { testId: 'agentic-storybook-interaction-tag-dismiss' }, value: true } }, + { expect: { state: 'focused', target: { testId: 'agentic-storybook-interaction-tag-primary' }, value: false } }, + { + action: 'screenshot', + name: 'interaction-tag-dismiss-focused', + target: { testId: 'agentic-storybook-interaction-tag' }, + }, + ], + }, + ], + } 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, layout, size, and availability variants.', + }, + }, + }, +}; + +export const Appearance: Story = { + render: () => ( + + {appearances.map(({ label, value }) => ( + + ))} + + ), + parameters: { + docs: { + description: { + story: 'Secondary is the default. Primary is the highest-emphasis surface and takes a lighter divider.', + }, + }, + }, +}; + +export const Layout: Story = { + render: () => ( + + + + + ), + parameters: { + docs: { + description: { + story: 'Icon only drops the content and always uses the circular radius, so the primary action needs its own name.', + }, + }, + }, +}; + +export const Size: Story = { + render: () => ( + + {sizes.map(({ label, value }) => ( + + + + + ))} + + ), + parameters: { + docs: { + description: { + story: 'Small and Medium sizes. Medium is the default, and the dismiss action stays at or above a 24 pixel target box.', + }, + }, + }, +}; + +export const Shape: Story = { + render: () => ( + + {shapes.map(({ label, value }) => ( + + ))} + + ), + parameters: { + docs: { + description: { + story: 'Each action rounds only its outer edge and stays square against the divider.', + }, + }, + }, +}; + +export const LeadingContent: Story = { + render: () => ( + + + + + + ), + parameters: { + docs: { + description: { + story: 'Leading content is an icon or an avatar, never both. Use an avatar for a person and an icon for a category.', + }, + }, + }, +}; + +export const Dismissing: Story = { + render: function DismissingStory() { + const [tags, setTags] = useState(['Design', 'Engineering', 'Research']); + + return ( + + {tags.map((tag) => ( + setTags((current) => current.filter((entry) => entry !== tag)), + }} + leadingIcon={leadingIcon} + /> + ))} + {tags.length === 0 ? All tags removed. : null} + + ); + }, + parameters: { + docs: { + description: { + story: 'The component never removes itself. The dismiss action reports onPress and the caller drops the tag from its own data.', + }, + }, + }, +}; + +export const Accessibility: Story = { + render: () => ( + + + + + ), + parameters: { + docs: { + description: { + story: + 'The dismiss action always needs an accessibilityLabel naming the tag it removes. An icon-only primary action needs one too.', + }, + }, + }, +}; + +export const ConstrainedText: Story = { + render: () => ( + + + + + ), + parameters: { + docs: { + description: { + story: 'The primary action shrinks when the surrounding layout constrains the width; the dismiss action keeps its target box.', + }, + }, + }, +}; + +const styles = StyleSheet.create({ + group: { + alignItems: 'flex-start', + gap: 8, + }, + label: { + fontSize: 12, + fontWeight: '600', + }, + longTag: { + width: 220, + }, + row: { + alignItems: 'center', + flexDirection: 'row', + flexWrap: 'wrap', + gap: 12, + }, + story: { + alignItems: 'flex-start', + gap: 16, + }, +}); diff --git a/packages/agentic/components/src/components/interaction-tag/interaction-tag.styles.ts b/packages/agentic/components/src/components/interaction-tag/interaction-tag.styles.ts new file mode 100644 index 0000000000..690700e301 --- /dev/null +++ b/packages/agentic/components/src/components/interaction-tag/interaction-tag.styles.ts @@ -0,0 +1,342 @@ +import { StyleSheet } from 'react-native'; +import type { TextStyle, ViewStyle } from 'react-native'; + +import { themedStyleSheetFactory } from '@fluentui-react-native/design'; +import type { FlexTokens } from '@fluentui-react-native/design'; + +import { + getGapStyleValue, + getNumericStyleValue, + getThemedColorStyleFactory, + getThemedStateStyleFactory, + interactiveStatePriority, +} from '@fluentui-react-native/design/styling'; +import type { + ColorStyleDefinition, + StateNames, + StyleDefinition, + TextColorStyle, + ViewColorStyle, +} from '@fluentui-react-native/design/styling'; +import type { PressableState } from '@fluentui-react-native/framework-base'; + +import type { AvatarSize } from '../avatar/avatar.types'; +import type { InteractionTagAppearance, InteractionTagSize, InteractionTagState } from './interaction-tag.types'; + +/** + * The minimum square target box for either action region. + */ +const minimumTargetBox = 24; + +export const interactionTagStyles = StyleSheet.create({ + action: { + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'center', + minHeight: minimumTargetBox, + minWidth: minimumTargetBox, + }, + content: { + flexShrink: 1, + textAlign: 'center', + }, + dismiss: { + flexShrink: 0, + }, + divider: { + alignSelf: 'stretch', + flexShrink: 0, + }, + icon: { + flexShrink: 0, + }, + primaryAction: { + flexShrink: 1, + }, + root: { + alignItems: 'stretch', + alignSelf: 'flex-start', + borderWidth: 0, + flexDirection: 'row', + minHeight: minimumTargetBox, + }, +}); + +export const getInteractionTagThemedStyles = themedStyleSheetFactory('InteractionTag.themed', ({ tokens }) => + StyleSheet.create({ + divider: { + width: getNumericStyleValue(tokens.strokeWidth.thin) as ViewStyle['width'], + }, + }), +); + +const backgroundStateLevels = [['primary', 'secondary'], interactiveStatePriority] as const; +type BackgroundStateLevels = typeof backgroundStateLevels; +type BackgroundState = StateNames; + +const backgroundDefinition: ColorStyleDefinition = { + primary: { + backgroundColor: 'backgroundBrandHeavy', + disabled: { + backgroundColor: 'backgroundNeutralHeavyDisabled', + }, + }, + secondary: { + backgroundColor: 'backgroundNeutralSubtle', + disabled: { + backgroundColor: 'backgroundNeutralSubtleDisabled', + }, + }, +}; + +const appearanceStateLevels = [['primary', 'secondary'], ['disabled']] as const; +type AppearanceStateLevels = typeof appearanceStateLevels; +type AppearanceState = InteractionTagAppearance | 'disabled'; + +const foregroundDefinition: ColorStyleDefinition = { + primary: { + color: 'foregroundNeutralOnloud', + disabled: { + color: 'foregroundNeutralDisabled', + }, + }, + secondary: { + color: 'foregroundNeutralPrimary', + disabled: { + color: 'foregroundNeutralDisabled', + }, + }, +}; + +const dividerDefinition: ColorStyleDefinition = { + primary: { + backgroundColor: 'strokeNeutralOnloud', + disabled: { + backgroundColor: 'strokeNeutralDisabled', + }, + }, + secondary: { + backgroundColor: 'strokeNeutralSubtle', + disabled: { + backgroundColor: 'strokeNeutralDisabled', + }, + }, +}; + +const getThemedBackgroundStyle = getThemedColorStyleFactory( + 'InteractionTag.background', + backgroundDefinition, + backgroundStateLevels, +); +const getThemedForegroundStyle = getThemedColorStyleFactory( + 'InteractionTag.foreground', + foregroundDefinition, + appearanceStateLevels, +); +const getThemedDividerStyle = getThemedColorStyleFactory( + 'InteractionTag.divider', + dividerDefinition, + appearanceStateLevels, +); + +function getAppearanceStateSource(state: InteractionTagState): AppearanceState[] { + const source: AppearanceState[] = [state.appearance]; + if (state.disabled) { + source.push('disabled'); + } + return source; +} + +function getBackgroundStateSource(state: InteractionTagState, region: PressableState): BackgroundState[] { + const source: BackgroundState[] = [state.appearance]; + if (state.disabled) { + source.push('disabled'); + } + if (region.pressed) { + source.push('pressed'); + } + if (region.hovered) { + source.push('hovered'); + } + return source; +} + +/** + * Resolves one action region's background from the tag appearance and that region's own interaction state, so the two + * regions never share a hover or pressed fill. + */ +export function getInteractionTagBackgroundStyle(state: InteractionTagState, region: PressableState): ViewColorStyle { + return getThemedBackgroundStyle(state, getBackgroundStateSource(state, region)); +} + +export function getInteractionTagForegroundStyle(state: InteractionTagState): TextColorStyle { + return getThemedForegroundStyle(state, getAppearanceStateSource(state)); +} + +export function getInteractionTagDividerColorStyle(state: InteractionTagState): ViewColorStyle { + return getThemedDividerStyle(state, getAppearanceStateSource(state)); +} + +const containerStateLevels = [ + ['small', 'medium'], + ['iconAndText', 'iconOnly'], + ['rounded', 'circular'], +] as const; +type ContainerStateLevels = typeof containerStateLevels; +type ContainerState = StateNames; + +function createRadiusDefinition( + { borderRadius }: FlexTokens, + toCorners: (radius: ViewStyle['borderRadius']) => ViewStyle, +): StyleDefinition { + return { + small: { + iconAndText: { + circular: toCorners(borderRadius.circular), + rounded: toCorners(borderRadius.base200), + }, + iconOnly: { + circular: toCorners(borderRadius.circular), + }, + }, + medium: { + iconAndText: { + circular: toCorners(borderRadius.circular), + rounded: toCorners(borderRadius.base300), + }, + iconOnly: { + circular: toCorners(borderRadius.circular), + }, + }, + }; +} + +function toContainerCorners(borderRadius: ViewStyle['borderRadius']): ViewStyle { + return { borderRadius }; +} + +function toLeadingCorners(borderRadius: ViewStyle['borderRadius']): ViewStyle { + return { + borderEndEndRadius: 0, + borderEndStartRadius: borderRadius, + borderStartEndRadius: 0, + borderStartStartRadius: borderRadius, + }; +} + +function toTrailingCorners(borderRadius: ViewStyle['borderRadius']): ViewStyle { + return { + borderEndEndRadius: borderRadius, + borderEndStartRadius: 0, + borderStartEndRadius: borderRadius, + borderStartStartRadius: 0, + }; +} + +const getThemedContainerStyle = getThemedStateStyleFactory( + 'InteractionTag.container', + (tokens: FlexTokens) => createRadiusDefinition(tokens, toContainerCorners), + containerStateLevels, +); +const getThemedLeadingCornerStyle = getThemedStateStyleFactory( + 'InteractionTag.leadingCorners', + (tokens: FlexTokens) => createRadiusDefinition(tokens, toLeadingCorners), + containerStateLevels, +); +const getThemedTrailingCornerStyle = getThemedStateStyleFactory( + 'InteractionTag.trailingCorners', + (tokens: FlexTokens) => createRadiusDefinition(tokens, toTrailingCorners), + containerStateLevels, +); + +function getContainerStateSource(state: InteractionTagState): ContainerState[] { + return [state.size, state.iconOnly ? 'iconOnly' : 'iconAndText', state.iconOnly ? 'circular' : state.shape]; +} + +export function getInteractionTagContainerStyle(state: InteractionTagState): ViewStyle { + return getThemedContainerStyle(state, getContainerStateSource(state)); +} + +/** + * Outer corners for the leading region. The edge that meets the divider stays square. + */ +export function getInteractionTagLeadingCornerStyle(state: InteractionTagState): ViewStyle { + return getThemedLeadingCornerStyle(state, getContainerStateSource(state)); +} + +/** + * Outer corners for the trailing region. The edge that meets the divider stays square. + */ +export function getInteractionTagTrailingCornerStyle(state: InteractionTagState): ViewStyle { + return getThemedTrailingCornerStyle(state, getContainerStateSource(state)); +} + +const sizeStateLevels = [['small', 'medium']] as const; + +function createActionStyleDefinition({ spacing }: FlexTokens): StyleDefinition { + return { + small: { + gap: getGapStyleValue(spacing.componentBase50), + paddingHorizontal: spacing.componentBase150, + paddingVertical: spacing.componentBase100, + }, + medium: { + gap: getGapStyleValue(spacing.componentBase50), + paddingHorizontal: spacing.componentBase200, + paddingVertical: spacing.componentBase150, + }, + }; +} + +const getThemedActionStyle = getThemedStateStyleFactory('InteractionTag.action', createActionStyleDefinition, sizeStateLevels); + +export function getInteractionTagActionStyle(state: InteractionTagState): ViewStyle { + return getThemedActionStyle(state, [state.size]); +} + +function createContentStyleDefinition({ + fontFamily, + fontSize, + fontWeight, + lineHeight, + spacing, +}: FlexTokens): StyleDefinition { + return { + fontFamily: fontFamily.functional, + small: { + fontSize: fontSize.functionalBodySmall, + fontWeight: fontWeight.functionalRegular, + lineHeight: lineHeight.functionalBodySmall, + paddingHorizontal: spacing.componentBase50, + }, + medium: { + fontSize: fontSize.functionalBodyMedium, + fontWeight: fontWeight.functionalRegular, + lineHeight: lineHeight.functionalBodyMedium, + paddingHorizontal: spacing.componentBase50, + }, + }; +} + +const getThemedContentStyle = getThemedStateStyleFactory('InteractionTag.content', createContentStyleDefinition, sizeStateLevels); + +export function getInteractionTagContentStyle(state: InteractionTagState): TextStyle { + return getThemedContentStyle(state, [state.size]); +} + +const iconSizes: Record = { + small: { + avatar: 16, + dismiss: 12, + leading: 16, + }, + medium: { + avatar: 20, + dismiss: 16, + leading: 20, + }, +}; + +export function getInteractionTagIconSize(size: InteractionTagSize): { avatar: AvatarSize; dismiss: number; leading: number } { + return iconSizes[size]; +} diff --git a/packages/agentic/components/src/components/interaction-tag/interaction-tag.test.tsx b/packages/agentic/components/src/components/interaction-tag/interaction-tag.test.tsx new file mode 100644 index 0000000000..3efa15b6bb --- /dev/null +++ b/packages/agentic/components/src/components/interaction-tag/interaction-tag.test.tsx @@ -0,0 +1,374 @@ +/** @jsxImportSource @fluentui-react-native/framework-base */ +import { StyleSheet } from 'react-native'; +import type { TextStyle, View, 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 { InteractionTag } from './interaction-tag'; + +const dismissProps = { accessibilityLabel: 'Remove tag', testID: 'dismiss-action' } as const; +const leadingIcon = { fontSource: { codepoint: 0x2605, fontFamily: 'Arial' }, testID: 'leading' } as const; +const dismissIcon = { fontSource: { codepoint: 0x2715, fontFamily: 'Arial' }, testID: 'dismiss-icon' } as const; + +function renderInteractionTag(props: React.ComponentProps = {}): Promise { + return render(); +} + +function getPrimary(component: RenderResult) { + return component.getByTestId('primary-action'); +} + +function getDismiss(component: RenderResult) { + return component.getByTestId('dismiss-action'); +} + +function flatten(node: { props: { style?: unknown } }): ViewStyle & TextStyle { + return StyleSheet.flatten(node.props.style as ViewStyle & TextStyle); +} + +const withPrimaryTestId = { primaryAction: { testID: 'primary-action' } } as const; + +function getHidden(component: RenderResult, testID: string) { + return component.getByTestId(testID, { includeHiddenElements: true }); +} + +describe('InteractionTag', () => { + it('renders a container holding two sibling button regions separated by a divider', async () => { + const component = await renderInteractionTag({ ...withPrimaryTestId, content: 'Engineering' }); + const root = component.getByTestId('root'); + + expect(root.props.role).toBeUndefined(); + expect(root.children).toHaveLength(3); + expect(getPrimary(component).props.role).toBe('button'); + expect(getDismiss(component).props.role).toBe('button'); + expect(component.getByText('Engineering')).toBeOnTheScreen(); + expect(flatten(root)).toMatchObject({ alignItems: 'stretch', flexDirection: 'row', minHeight: 24 }); + expect(flatten(root).backgroundColor).toBeUndefined(); + }); + + it('renders the primary action, the divider, and the dismiss action in order', async () => { + const component = await renderInteractionTag({ ...withPrimaryTestId, content: 'Engineering', leadingIcon }); + const [primary, divider, dismiss] = component.getByTestId('root').children as ReturnType[]; + + expect(primary.props.testID).toBe('primary-action'); + expect(divider.props.accessible).toBe(false); + expect(divider.props.pointerEvents).toBe('none'); + expect(dismiss.props.testID).toBe('dismiss-action'); + expect(primary.children).toHaveLength(3); + expect(dismiss.children).toHaveLength(2); + }); + + it('fires each region handler independently without crossing over', async () => { + const onPrimaryPress = jest.fn(); + const onDismissPress = jest.fn(); + const component = await renderInteractionTag({ + content: 'Engineering', + dismiss: { ...dismissProps, onPress: onDismissPress }, + primaryAction: { onPress: onPrimaryPress, testID: 'primary-action' }, + }); + + await fireEvent.press(getDismiss(component)); + expect(onDismissPress).toHaveBeenCalledTimes(1); + expect(onPrimaryPress).not.toHaveBeenCalled(); + + await fireEvent.press(getPrimary(component)); + expect(onPrimaryPress).toHaveBeenCalledTimes(1); + expect(onDismissPress).toHaveBeenCalledTimes(1); + }); + + it('keeps hover and pressed backgrounds isolated to the region being interacted with', async () => { + const component = await renderInteractionTag({ ...withPrimaryTestId, content: 'Engineering' }); + const restBackground = flatten(getPrimary(component)).backgroundColor; + + expect(flatten(getDismiss(component)).backgroundColor).toBe(restBackground); + + await fireEvent(getDismiss(component), 'hoverIn', {}); + expect(flatten(getDismiss(component)).backgroundColor).not.toBe(restBackground); + expect(flatten(getPrimary(component)).backgroundColor).toBe(restBackground); + + await fireEvent(getPrimary(component), 'pressIn', {}); + expect(flatten(getPrimary(component)).backgroundColor).toBe(defaultFlexTokens.color.pressed.backgroundNeutralSubtle); + }); + + it('keeps the foreground stable across hover and pressed', async () => { + const component = await renderInteractionTag({ ...withPrimaryTestId, appearance: 'primary', content: 'Engineering' }); + const text = component.getByText('Engineering'); + + expect(flatten(text).color).toBe(defaultFlexTokens.color.foregroundNeutralOnloud); + expect(flatten(getPrimary(component)).backgroundColor).toBe(defaultFlexTokens.color.backgroundBrandHeavy); + + await fireEvent(getPrimary(component), 'hoverIn', {}); + expect(flatten(getPrimary(component)).backgroundColor).toBe(defaultFlexTokens.color.hover.backgroundBrandHeavy); + expect(flatten(text).color).toBe(defaultFlexTokens.color.foregroundNeutralOnloud); + }); + + it('disables both regions together', async () => { + const onPrimaryPress = jest.fn(); + const onDismissPress = jest.fn(); + const component = await renderInteractionTag({ + content: 'Unavailable', + disabled: true, + dismiss: { ...dismissProps, onPress: onDismissPress }, + primaryAction: { onPress: onPrimaryPress, testID: 'primary-action' }, + }); + + for (const region of [getPrimary(component), getDismiss(component)]) { + expect(region).toBeDisabled(); + expect(region.props.focusable).toBe(false); + expect(region.props.accessibilityState).toEqual({ disabled: true }); + expect(flatten(region).backgroundColor).toBe(defaultFlexTokens.color.backgroundNeutralSubtleDisabled); + } + + await fireEvent.press(getPrimary(component)); + await fireEvent.press(getDismiss(component)); + expect(onPrimaryPress).not.toHaveBeenCalled(); + expect(onDismissPress).not.toHaveBeenCalled(); + }); + + it('keeps disabled regions out of the tab order when slot props request focusability', async () => { + const component = await renderInteractionTag({ + content: 'Unavailable', + disabled: true, + dismiss: { ...dismissProps, focusable: true }, + primaryAction: { focusable: true, testID: 'primary-action' }, + }); + + expect(getPrimary(component).props.focusable).toBe(false); + expect(getDismiss(component).props.focusable).toBe(false); + }); + + it('disables the native focus ring for both custom focus-visual regions', async () => { + const component = await renderInteractionTag({ ...withPrimaryTestId, content: 'Engineering' }); + + expect(getPrimary(component).props.enableFocusRing).toBe(false); + expect(getDismiss(component).props.enableFocusRing).toBe(false); + }); + + it('merges caller accessibility state under the resolved disabled state', async () => { + const component = await renderInteractionTag({ + content: 'Engineering', + primaryAction: { accessibilityState: { busy: true }, testID: 'primary-action' }, + }); + + expect(getPrimary(component).props.accessibilityState).toEqual({ busy: true, disabled: false }); + }); + + it('prefers the avatar and warns when both leading slots are supplied', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(); + const component = await renderInteractionTag({ + ...withPrimaryTestId, + avatar: { initials: 'CE', testID: 'avatar' }, + content: 'Cameron Evans', + leadingIcon, + } as React.ComponentProps); + + expect(getHidden(component, 'avatar')).toBeOnTheScreen(); + expect(component.queryByTestId('leading', { includeHiddenElements: true })).toBeNull(); + expect(warn).toHaveBeenCalledWith('InteractionTag: provide a leading icon or an avatar, not both. The avatar is used.'); + warn.mockRestore(); + }); + + it('hides the avatar and both glyphs from the accessibility tree', async () => { + const component = await renderInteractionTag({ + ...withPrimaryTestId, + avatar: { initials: 'CE', testID: 'avatar' }, + content: 'Cameron Evans', + dismissIcon, + }); + + expect(getHidden(component, 'avatar').props.accessible).toBe(false); + expect(getHidden(component, 'avatar').props.importantForAccessibility).toBe('no-hide-descendants'); + expect(getHidden(component, 'dismiss-icon').props.accessible).toBe(false); + }); + + it('renders the default content and suppresses it in the icon-only layout', async () => { + const withText = await renderInteractionTag({ ...withPrimaryTestId }); + expect(withText.getByText('Tag text')).toBeOnTheScreen(); + + const iconOnly = await renderInteractionTag({ + layout: 'iconOnly', + leadingIcon, + primaryAction: { accessibilityLabel: 'Open Engineering', testID: 'primary-action' }, + }); + expect(iconOnly.queryByText('Tag text')).toBeNull(); + expect(getPrimary(iconOnly).props.accessibilityLabel).toBe('Open Engineering'); + }); + + it('forces the circular radius for the icon-only layout regardless of shape', async () => { + const component = await renderInteractionTag({ + layout: 'iconOnly', + leadingIcon, + primaryAction: { accessibilityLabel: 'Open Engineering', testID: 'primary-action' }, + shape: 'rounded', + }); + + expect(flatten(component.getByTestId('root')).borderRadius).toBe(defaultFlexTokens.borderRadius.circular); + expect(flatten(getPrimary(component))).toMatchObject({ + borderEndEndRadius: 0, + borderEndStartRadius: defaultFlexTokens.borderRadius.circular, + borderStartEndRadius: 0, + borderStartStartRadius: defaultFlexTokens.borderRadius.circular, + }); + }); + + it('rounds only the outer edge of each region', async () => { + const component = await renderInteractionTag({ ...withPrimaryTestId, content: 'Engineering' }); + const radius = defaultFlexTokens.borderRadius.base300; + + expect(flatten(component.getByTestId('root')).borderRadius).toBe(radius); + expect(flatten(getPrimary(component))).toMatchObject({ + borderEndEndRadius: 0, + borderEndStartRadius: radius, + borderStartEndRadius: 0, + borderStartStartRadius: radius, + }); + expect(flatten(getDismiss(component))).toMatchObject({ + borderEndEndRadius: radius, + borderEndStartRadius: 0, + borderStartEndRadius: radius, + borderStartStartRadius: 0, + }); + }); + + it('draws a hairline divider whose color follows appearance and disabled', async () => { + const secondary = await renderInteractionTag({ ...withPrimaryTestId, content: 'Engineering' }); + const dividerOf = (component: RenderResult) => flatten(component.getByTestId('root').children[1] as never); + + expect(dividerOf(secondary)).toMatchObject({ + alignSelf: 'stretch', + backgroundColor: defaultFlexTokens.color.strokeNeutralSubtle, + width: defaultFlexTokens.strokeWidth.thin, + }); + + const primary = await renderInteractionTag({ ...withPrimaryTestId, appearance: 'primary', content: 'Engineering' }); + expect(dividerOf(primary).backgroundColor).toBe(defaultFlexTokens.color.strokeNeutralOnloud); + + const disabled = await renderInteractionTag({ ...withPrimaryTestId, content: 'Engineering', disabled: true }); + expect(dividerOf(disabled).backgroundColor).toBe(defaultFlexTokens.color.strokeNeutralDisabled); + }); + + it('shows one focus visual per focused region', async () => { + const component = await renderInteractionTag({ ...withPrimaryTestId, content: 'Engineering' }); + const ringStyle = (testID: string) => flatten(component.getByTestId(testID, { includeHiddenElements: true })); + + expect(ringStyle('focus-visual-primary-action').opacity).toBe(0); + expect(ringStyle('focus-visual-dismiss').opacity).toBe(0); + + await fireEvent(getDismiss(component), 'focus', {}); + expect(ringStyle('focus-visual-dismiss')).toMatchObject({ + borderColor: defaultFlexTokens.color.strokeFocusOuter, + borderEndEndRadius: defaultFlexTokens.borderRadius.base300, + borderStartStartRadius: 0, + borderWidth: defaultFlexTokens.strokeWidth.thick, + }); + expect(ringStyle('focus-visual-dismiss')).not.toHaveProperty('opacity'); + expect(ringStyle('focus-visual-primary-action').opacity).toBe(0); + expect(ringStyle('focus-visual-dismiss-inner')).toMatchObject({ + borderColor: defaultFlexTokens.color.strokeFocusInner, + borderWidth: defaultFlexTokens.strokeWidth.thin, + }); + }); + + it('hides both focus visuals while disabled', async () => { + const component = await renderInteractionTag({ ...withPrimaryTestId, content: 'Engineering', disabled: true }); + + await fireEvent(getPrimary(component), 'focus', {}); + expect(flatten(component.getByTestId('focus-visual-primary-action', { includeHiddenElements: true })).opacity).toBe(0); + }); + + it('warns when the required accessible names are missing', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(); + await render(); + + expect(warn).toHaveBeenCalledWith('InteractionTag: icon-only tags require an accessibilityLabel on the primaryAction slot.'); + expect(warn).toHaveBeenCalledWith('InteractionTag: the dismiss slot requires an accessibilityLabel that names the tag it removes.'); + warn.mockRestore(); + }); + + it('warns when an icon-only tag has no leading content', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(); + await renderInteractionTag({ layout: 'iconOnly', primaryAction: { accessibilityLabel: 'Open Engineering' } }); + + expect(warn).toHaveBeenCalledWith('InteractionTag: icon-only tags require a leading icon or an avatar.'); + warn.mockRestore(); + }); + + it.each([ + ['primary', defaultFlexTokens.color.backgroundBrandHeavy], + ['secondary', defaultFlexTokens.color.backgroundNeutralSubtle], + ] as const)('resolves the %s appearance on both regions', async (appearance, backgroundColor) => { + const component = await renderInteractionTag({ ...withPrimaryTestId, appearance, content: appearance }); + + expect(flatten(getPrimary(component)).backgroundColor).toBe(backgroundColor); + expect(flatten(getDismiss(component)).backgroundColor).toBe(backgroundColor); + }); + + it.each([ + ['small', 12, 16, 12, 6, 4], + ['medium', 14, 20, 16, 8, 6], + ] as const)('resolves the %s size', async (size, fontSize, leadingSize, dismissSize, paddingHorizontal, paddingVertical) => { + const component = await renderInteractionTag({ + ...withPrimaryTestId, + content: size, + dismissIcon, + leadingIcon, + size, + }); + + expect(flatten(component.getByText(size)).fontSize).toBe(fontSize); + expect(flatten(component.getByTestId('leading'))).toMatchObject({ height: leadingSize, width: leadingSize }); + expect(flatten(component.getByTestId('dismiss-icon'))).toMatchObject({ height: dismissSize, width: dismissSize }); + expect(flatten(getDismiss(component))).toMatchObject({ minHeight: 24, minWidth: 24, paddingHorizontal, paddingVertical }); + }); + + it.each([ + ['small', 16], + ['medium', 20], + ] as const)('sizes the avatar for the %s size', async (size, avatarSize) => { + const component = await renderInteractionTag({ + ...withPrimaryTestId, + avatar: { initials: 'CE', testID: 'avatar' }, + content: 'Cameron Evans', + size, + }); + + expect(flatten(getHidden(component, 'avatar'))).toMatchObject({ height: avatarSize, width: avatarSize }); + }); + + it.each([ + ['rounded', defaultFlexTokens.borderRadius.base300], + ['circular', defaultFlexTokens.borderRadius.circular], + ] as const)('resolves the %s shape', async (shape, borderRadius) => { + const component = await renderInteractionTag({ ...withPrimaryTestId, content: shape, shape }); + + expect(flatten(component.getByTestId('root')).borderRadius).toBe(borderRadius); + }); + + it('applies the user style last on the container', async () => { + const component = await renderInteractionTag({ ...withPrimaryTestId, content: 'Engineering', style: { minHeight: 48 } }); + + expect(flatten(component.getByTestId('root')).minHeight).toBe(48); + }); + + it('forwards refs to the container and to each action region', async () => { + const root = { current: null } as React.RefObject; + const primary = { current: null } as React.RefObject; + const dismiss = { current: null } as React.RefObject; + + await render( + , + ); + + expect(root.current).not.toBeNull(); + expect(primary.current).not.toBeNull(); + expect(dismiss.current).not.toBeNull(); + }); +}); diff --git a/packages/agentic/components/src/components/interaction-tag/interaction-tag.ts b/packages/agentic/components/src/components/interaction-tag/interaction-tag.ts new file mode 100644 index 0000000000..2b341ef6aa --- /dev/null +++ b/packages/agentic/components/src/components/interaction-tag/interaction-tag.ts @@ -0,0 +1,16 @@ +import { directComponent, phasedComponent } from '@fluentui-react-native/framework-base'; + +import { renderInteractionTag_unstable } from './renderInteractionTag'; +import type { InteractionTagProps } from './interaction-tag.types'; +import { useInteractionTagStyles_unstable } from './useInteractionTagStyles'; +import { useInteractionTag_unstable } from './useInteractionTag'; + +export const InteractionTag = phasedComponent((props) => { + const state = useInteractionTag_unstable(props); + useInteractionTagStyles_unstable(state); + return directComponent(() => renderInteractionTag_unstable(state)); +}); + +InteractionTag.displayName = 'InteractionTag'; + +export default InteractionTag; diff --git a/packages/agentic/components/src/components/interaction-tag/interaction-tag.types.test.ts b/packages/agentic/components/src/components/interaction-tag/interaction-tag.types.test.ts new file mode 100644 index 0000000000..8ac0d00e33 --- /dev/null +++ b/packages/agentic/components/src/components/interaction-tag/interaction-tag.types.test.ts @@ -0,0 +1,83 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import * as React from 'react'; +import { View } from 'react-native'; +import type { SlotProp } from '@fluentui-react-native/framework-base'; +import { directComponent } from '@fluentui-react-native/framework-base'; + +import type { IconElementProps } from '../../primitives/icon/icon.types'; +import type { Icon } from '../../primitives/icon/icon'; +import type { InteractionTag } from './interaction-tag'; +import type { InteractionTagProps, InteractionTagSlots, InteractionTagStateSlots } from './interaction-tag.types'; + +const LeadingIcon: SlotProp = { fontSource: { codepoint: 0x2605, fontFamily: 'Arial' } }; +const DismissIcon: SlotProp = { fontSource: { codepoint: 0x2715, fontFamily: 'Arial' } }; +const Replacement = directComponent((props) => React.createElement(View, props)); + +const containerRef = React.createRef(); +const primaryRef = React.createRef(); +const dismissRef = React.createRef(); + +const InteractionTagSlot: SlotProp = { + content: 'Engineering', + dismiss: { accessibilityLabel: 'Remove Engineering', onPress: () => undefined }, + dismissIcon: { as: Replacement, fontSource: { codepoint: 0x2715, fontFamily: 'Arial' } }, + leadingIcon: LeadingIcon, + primaryAction: { onPress: () => undefined }, +}; + +const WithLeadingIcon: InteractionTagProps = { + appearance: 'primary', + content: 'Engineering', + dismiss: { accessibilityLabel: 'Remove Engineering', onPress: () => undefined }, + dismissIcon: DismissIcon, + leadingIcon: LeadingIcon, + primaryAction: { onPress: () => undefined }, + shape: 'circular', + size: 'small', +}; + +const WithAvatar: InteractionTagProps = { + avatar: { initials: 'CE' }, + content: 'Cameron Evans', + dismiss: { accessibilityLabel: 'Remove Cameron Evans', onPress: () => undefined }, +}; + +const WithRefs: InteractionTagProps = { + content: 'Engineering', + dismiss: { accessibilityLabel: 'Remove Engineering', ref: dismissRef }, + primaryAction: { ref: primaryRef }, + ref: containerRef, +}; + +const IconOnly: InteractionTagProps = { + layout: 'iconOnly', + leadingIcon: LeadingIcon, + primaryAction: { accessibilityLabel: 'Open Engineering' }, +}; + +// @ts-expect-error leading content is an icon or an avatar, never both. +const BothLeadingSlots: InteractionTagProps = { + avatar: { initials: 'CE' }, + content: 'Cameron Evans', + leadingIcon: LeadingIcon, +}; + +const InternalSlotName: keyof InteractionTagStateSlots = 'divider'; +// @ts-expect-error the divider is structural and is not one of the public slots. +const PublicSlotName: keyof InteractionTagSlots = 'divider'; + +describe('InteractionTag types', () => { + it('accepts the leading icon, avatar, ref, and icon-only branches', () => { + expect(InteractionTagSlot).toBeDefined(); + expect(WithLeadingIcon).toBeDefined(); + expect(WithAvatar).toBeDefined(); + expect(WithRefs).toBeDefined(); + expect(IconOnly).toBeDefined(); + }); + + it('rejects mixed leading content and keeps the divider out of the public slots', () => { + expect(BothLeadingSlots).toBeDefined(); + expect(InternalSlotName).toBe('divider'); + expect(PublicSlotName).toBe('divider'); + }); +}); diff --git a/packages/agentic/components/src/components/interaction-tag/interaction-tag.types.ts b/packages/agentic/components/src/components/interaction-tag/interaction-tag.types.ts new file mode 100644 index 0000000000..c03ceb71cb --- /dev/null +++ b/packages/agentic/components/src/components/interaction-tag/interaction-tag.types.ts @@ -0,0 +1,120 @@ +import type * as React from 'react'; +import type { Pressable, StyleProp, View, ViewStyle } from 'react-native'; +import type { + ComponentProps, + ComponentState, + OptionalSlot, + OptionalSlotProp, + OwnedRootProps, + PressableState, + PropsOf, + PropsWithRefOf, + Slot, +} from '@fluentui-react-native/framework-base'; +import type { ThemeState } from '@fluentui-react-native/design'; + +import type { FocusVisualProps } from '../../primitives/focus-visual/focus-visual.types'; +import type { Icon } from '../../primitives/icon/icon'; +import type { Avatar } from '../avatar/avatar'; +import type { Text } from '../text/text'; + +/** + * The public slots of an interaction tag. The container root is not interactive; the two action regions are siblings + * inside it so a press on one never reaches the other. + */ +export type InteractionTagSlots = { + root: Slot; + primaryAction: Slot; + avatar: OptionalSlot; + leadingIcon: OptionalSlot; + content: OptionalSlot; + dismiss: Slot; + dismissIcon: OptionalSlot; +}; + +/** + * The rendered slots, including the structural divider that separates the two action regions. The divider is not part + * of the public API. + */ +export type InteractionTagStateSlots = InteractionTagSlots & { + divider: Slot; +}; + +export type InteractionTagAppearance = 'primary' | 'secondary'; +export type InteractionTagLayout = 'iconAndText' | 'iconOnly'; +export type InteractionTagSize = 'small' | 'medium'; +export type InteractionTagShape = 'rounded' | 'circular'; + +export type InteractionTagStateProps = { + /** + * Selects the background, foreground, and divider color family for both action regions. + */ + appearance?: InteractionTagAppearance; + + /** + * Blocks both action regions, removes both from the tab order, and selects the disabled colors. + */ + disabled?: boolean; + + /** + * `iconOnly` suppresses the content and forces the circular radius. + */ + layout?: InteractionTagLayout; + + /** + * Selects the container corner radius in the icon-and-text layout. Ignored in the icon-only layout. + */ + shape?: InteractionTagShape; + + /** + * Selects the action padding, the text style, and the leading, avatar, and dismiss glyph sizes. + */ + size?: InteractionTagSize; +}; + +export type InteractionTagRootProps = OwnedRootProps, 'accessibilityRole' | 'role'>; + +/** + * Props for either action region. Children are owned by the component, so an action slot accepts a props object and + * never a shorthand child. + */ +export type InteractionTagActionProps = { + as?: React.ComponentType>; +} & OwnedRootProps, 'accessibilityRole' | 'role'>; + +/** + * Leading content is an icon or an avatar, never both. + */ +export type InteractionTagLeadingContentProps = + | { avatar?: OptionalSlotProp; leadingIcon?: never } + | { avatar?: never; leadingIcon?: OptionalSlotProp }; + +type InteractionTagOwnProps = InteractionTagStateProps & + Omit, 'avatar' | 'dismiss' | 'leadingIcon' | 'primaryAction'> & { + /** + * The trailing action region that reports removal. It always needs an `accessibilityLabel`. + */ + dismiss?: InteractionTagActionProps; + + /** + * The leading action region that inspects or opens the thing the tag names. + */ + primaryAction?: InteractionTagActionProps; + }; + +export type InteractionTagProps = InteractionTagOwnProps & InteractionTagLeadingContentProps; + +export type InteractionTagState = ComponentState & + Required & + Omit & { + dismissFocusVisualProps?: FocusVisualProps; + dismissState: PressableState; + hasAvatar: boolean; + hasContent: boolean; + hasLeadingContent: boolean; + hasLeadingIcon: boolean; + iconOnly: boolean; + primaryFocusVisualProps?: FocusVisualProps; + primaryState: PressableState; + userStyle?: StyleProp; + }; diff --git a/packages/agentic/components/src/components/interaction-tag/renderInteractionTag.tsx b/packages/agentic/components/src/components/interaction-tag/renderInteractionTag.tsx new file mode 100644 index 0000000000..3a2d7164e6 --- /dev/null +++ b/packages/agentic/components/src/components/interaction-tag/renderInteractionTag.tsx @@ -0,0 +1,21 @@ +/** @jsxImportSource @fluentui-react-native/framework-base */ +import { FocusVisual } from '../../primitives/focus-visual/focus-visual'; +import type { InteractionTagState } from './interaction-tag.types'; + +export function renderInteractionTag_unstable(state: InteractionTagState) { + return ( + + + + {state.avatar && } + {state.leadingIcon && } + {state.content && } + + + + + {state.dismissIcon && } + + + ); +} diff --git a/packages/agentic/components/src/components/interaction-tag/spec/accessibility.md b/packages/agentic/components/src/components/interaction-tag/spec/accessibility.md new file mode 100644 index 0000000000..8ef7520f9f --- /dev/null +++ b/packages/agentic/components/src/components/interaction-tag/spec/accessibility.md @@ -0,0 +1,78 @@ +# InteractionTag accessibility + +## Two controls, one tag + +An interaction tag is two accessible controls, not one. The primary action and +the dismiss action are each exposed as a button, and the container between them +is not an accessibility element at all: it sets no role and does not merge its +children. A screen reader user therefore meets the primary action and the +dismiss action as separate, adjacent items. + +This is deliberate. The dismiss action performs a different, irreversible +operation from the primary action, so it needs its own name and its own +activation point. + +## Names + +The primary action's name comes from its content in the icon-and-text layout, so +the rendered text is the name and no extra property is needed. In the icon-only +layout there is no text to read, so the caller must supply an +`accessibilityLabel` on the primary action slot; development builds warn when it +is missing. + +The dismiss action always needs an `accessibilityLabel`, in every layout and at +every size, because it contains only a decorative glyph. Name it for the tag it +removes rather than for the glyph, for example `Remove Engineering` rather than +`Close` or `X`. Development builds warn when it is missing. + +Neither name should include the word button. The role already says that. + +## Hidden parts + +Three parts are removed from the accessibility tree so they cannot leak into +either name: + +- The leading icon and the dismiss glyph are marked not accessible. They are + decoration for a control that is already named. +- The avatar is hidden together with its subtree, because its image or its + initials text would otherwise be read as part of the primary action's name. + Repeat the person's name in the primary action's own text or label instead. +- The divider is hidden and is excluded from hit testing. It is a hairline with + no meaning. + +## State + +Both regions carry the resolved `disabled` state on their accessibility state, +merged over any state the caller passes, so assistive technology reports a +disabled tag consistently on both controls. While disabled, both regions also +leave the tab order rather than staying focusable and inert. + +InteractionTag has no selected, checked, expanded, or busy state to report. + +## Keyboard reachability + +Each enabled region is its own tab stop, so an enabled tag costs two tab stops. +Callers who need a single stop per tag should place tags inside a focus-managing +container rather than asking the component to merge its two controls. + +Both regions accept keyboard activation through the shared pressable behavior, +so Enter and Space fire the same `onPress` a pointer press fires. + +## Focus visibility + +Focus is shown by a two-ring focus visual inside the focused region, drawn from +the focus stroke tokens. Both rings stay mounted and change only opacity, and +they never rely on the platform's own focus ring. The visual follows the outer +corner radii of the region it sits in, so a focused dismiss action on a circular +tag shows a rounded trailing edge and a flat leading edge. + +Only the focused region shows a ring. Focusing the dismiss action never lights +the primary action, because the two regions hold separate focus state. + +## Contrast and targets + +The foreground is resolved from appearance and disabled only, so it does not +change on hover or press and cannot drift below its intended contrast during an +interaction. Both regions reach at least a twenty-four pixel square target box +at both sizes; at the medium size the dismiss action is a thirty-two pixel +square. diff --git a/packages/agentic/components/src/components/interaction-tag/spec/interaction.md b/packages/agentic/components/src/components/interaction-tag/spec/interaction.md new file mode 100644 index 0000000000..566eb7ffbe --- /dev/null +++ b/packages/agentic/components/src/components/interaction-tag/spec/interaction.md @@ -0,0 +1,63 @@ +# InteractionTag interaction + +## Two independent regions + +The primary action and the dismiss action are siblings inside a plain container. +The container is a `View` with no press handling, so the two regions are never +nested and no press event travels from one to the other. + +Each region runs its own pressable state, which means: + +- Hovering the dismiss action fills the dismiss action only. The primary action + stays at rest. +- Pressing the dismiss action fires the dismiss handler only. The primary + handler does not also run. +- Focusing one region shows one focus visual. The other region shows none. + +The divider and both focus visuals are excluded from hit testing, so a pointer +that lands anywhere on the tag lands on exactly one of the two regions. + +## Pointer + +Both regions follow the shared pressable model: hover applies the hovered +background, pointer down applies the pressed background, and releasing inside +the region fires `onPress`. Releasing outside the region cancels without firing, +and the region returns to rest. + +Backgrounds change immediately, with no transition, because this package +publishes no motion tokens. + +## Keyboard + +Tab reaches the primary action and then the dismiss action, in render order, so +a right-to-left surface still visits the primary action first. Enter and Space +activate the focused region and fire the same handler a pointer press fires. + +InteractionTag adds no arrow key behavior, no Delete or Backspace shortcut, and +no type-ahead. A row of tags that needs arrow navigation should be wrapped in a +focus-managing container that owns those keys. + +## Disabled + +The `disabled` prop governs both regions together. While disabled, neither +region hovers, presses, fires a handler, or takes focus, and both leave the tab +order. The disabled background and foreground apply to both regions and the +divider takes its own disabled color. + +## Focus visual + +Each region draws its own two-ring focus visual, mounted at all times and +toggled by opacity so no border visual is created after mount. The visual +follows that region's resolved outer corner radii and stays flat on the edge +that meets the divider. + +## Dismissal + +The component never removes itself. Activating the dismiss action reports +`onPress` and nothing else; the caller drops the tag from its own collection and +the tag unmounts. Callers that need to confirm the removal, animate it, or undo +it own that behavior entirely. + +Move focus deliberately after a removal. The dismissed tag's two tab stops +disappear with it, so a caller that removes the last tag in a row should send +focus somewhere sensible rather than letting it fall back to the surface. diff --git a/packages/agentic/components/src/components/interaction-tag/spec/source.json b/packages/agentic/components/src/components/interaction-tag/spec/source.json new file mode 100644 index 0000000000..754549650f --- /dev/null +++ b/packages/agentic/components/src/components/interaction-tag/spec/source.json @@ -0,0 +1,148 @@ +{ + "schemaVersion": 2, + "component": "interaction-tag", + "lifecycle": "implemented", + "conformance": "reviewed", + "reviewedAt": "2026-02-19", + "sources": [ + { + "id": "flex-component", + "kind": "flex-skill", + "authority": "normative", + "skill": "flex-components:interaction-tag", + "sourceLock": "flex-1.5.0-206c4996", + "sourceLockFingerprint": "a69997212ec1b89510c94176801bf5a146ed7e7d8c80cc7db40ac8f60cf9f119", + "availableSurfaces": ["shared", "web"], + "surfacesConsulted": ["shared", "web"], + "sourceFiles": [ + { + "role": "skill", + "marketplacePath": "catalogs/flex/plugins/components/skills/interaction-tag/SKILL.md", + "marketplaceBlobSha": "75c8397dfc769d9ea9ed4c9000352cdb283cf3b2", + "marketplaceSha256": "da41d3fef67e3eee449f680aa33da59049811ac6480dfdb7fec2ae8850419dca", + "originPath": "plugins/components/skills/interaction-tag/SKILL.md", + "originBlobSha": "75c8397dfc769d9ea9ed4c9000352cdb283cf3b2", + "originSha256": "da41d3fef67e3eee449f680aa33da59049811ac6480dfdb7fec2ae8850419dca", + "contentDiffers": false + }, + { + "role": "usage", + "marketplacePath": "catalogs/flex/plugins/components/skills/interaction-tag/usage.md", + "marketplaceBlobSha": "7d45e54336c453b43c46942da9897e94bec64428", + "marketplaceSha256": "075fb065b243176e24871d842d403e75b20c24d95a3a53ac0e64843d52d5bf39", + "originPath": "plugins/components/skills/interaction-tag/usage.md", + "originBlobSha": "7d45e54336c453b43c46942da9897e94bec64428", + "originSha256": "075fb065b243176e24871d842d403e75b20c24d95a3a53ac0e64843d52d5bf39", + "contentDiffers": false + }, + { + "role": "web:accessibility", + "marketplacePath": "catalogs/flex/plugins/components/skills/interaction-tag/web/accessibility.md", + "marketplaceBlobSha": "9c597c536fef64fb06df3680ac5149258e14e7dd", + "marketplaceSha256": "0ef6945803c6e928c2ce2c874c3975b28c747c7170127ff612e0d41c6f215587", + "originPath": "plugins/components/skills/interaction-tag/web/accessibility.md", + "originBlobSha": "9c597c536fef64fb06df3680ac5149258e14e7dd", + "originSha256": "0ef6945803c6e928c2ce2c874c3975b28c747c7170127ff612e0d41c6f215587", + "contentDiffers": false + }, + { + "role": "web:interaction", + "marketplacePath": "catalogs/flex/plugins/components/skills/interaction-tag/web/interaction.md", + "marketplaceBlobSha": "59dfb2d99cc719e68c96313063a3123fdca9fe87", + "marketplaceSha256": "bfed40c29413b8bc20ef0b38829faf4d05ab353ef4664b0a0269eba9b1dd2d81", + "originPath": "plugins/components/skills/interaction-tag/web/interaction.md", + "originBlobSha": "59dfb2d99cc719e68c96313063a3123fdca9fe87", + "originSha256": "bfed40c29413b8bc20ef0b38829faf4d05ab353ef4664b0a0269eba9b1dd2d81", + "contentDiffers": false + }, + { + "role": "web:tokens", + "marketplacePath": "catalogs/flex/plugins/components/skills/interaction-tag/web/tokens.yaml", + "marketplaceBlobSha": "d140dbc2d17810766be44440cdaeded5530aba74", + "marketplaceSha256": "e8f074975952e539093cc1be73eb1e6c08758927252e2ac86b6527474f8d8298", + "originPath": "plugins/components/skills/interaction-tag/web/tokens.yaml", + "originBlobSha": "d140dbc2d17810766be44440cdaeded5530aba74", + "originSha256": "e8f074975952e539093cc1be73eb1e6c08758927252e2ac86b6527474f8d8298", + "contentDiffers": false + } + ], + "releaseDifferences": [] + } + ], + "divergences": [ + { + "id": "interaction-tag-appearance-prop-name", + "status": "accepted" + }, + { + "id": "interaction-tag-dismiss-is-caller-owned", + "status": "accepted" + }, + { + "id": "interaction-tag-leading-content-is-slots", + "status": "accepted" + }, + { + "id": "interaction-tag-no-state-transition", + "status": "accepted" + }, + { + "id": "interaction-tag-single-disabled-axis", + "status": "accepted" + } + ], + "requirements": [ + { + "id": "ITAG-001", + "evidence": ["interaction-tag.types.ts", "useInteractionTag.ts", "interaction-tag.types.test.ts", "interaction-tag.test.tsx"] + }, + { + "id": "ITAG-002", + "evidence": ["useInteractionTag.ts", "renderInteractionTag.tsx", "interaction-tag.test.tsx", "interaction-tag.stories.tsx"] + }, + { + "id": "ITAG-003", + "evidence": ["renderInteractionTag.tsx", "interaction-tag.test.tsx"] + }, + { + "id": "ITAG-004", + "evidence": ["interaction-tag.types.ts", "useInteractionTag.ts", "interaction-tag.types.test.ts", "interaction-tag.test.tsx"] + }, + { + "id": "ITAG-005", + "evidence": ["useInteractionTag.ts", "interaction-tag.test.tsx"] + }, + { + "id": "ITAG-006", + "evidence": ["interaction-tag.styles.ts", "useInteractionTagStyles.ts", "interaction-tag.test.tsx"] + }, + { + "id": "ITAG-007", + "evidence": ["interaction-tag.styles.ts", "useInteractionTagStyles.ts", "interaction-tag.test.tsx"] + }, + { + "id": "ITAG-008", + "evidence": ["interaction-tag.styles.ts", "interaction-tag.test.tsx", "interaction-tag.stories.tsx"] + }, + { + "id": "ITAG-009", + "evidence": ["interaction-tag.styles.ts", "useInteractionTag.ts", "useInteractionTagStyles.ts", "interaction-tag.test.tsx"] + }, + { + "id": "ITAG-010", + "evidence": ["interaction-tag.styles.ts", "useInteractionTagStyles.ts", "interaction-tag.test.tsx"] + }, + { + "id": "ITAG-011", + "evidence": ["useInteractionTag.ts", "interaction-tag.test.tsx"] + }, + { + "id": "ITAG-012", + "evidence": ["useInteractionTagStyles.ts", "interaction-tag.test.tsx", "interaction-tag.stories.tsx"] + }, + { + "id": "ITAG-013", + "evidence": ["useInteractionTag.ts", "interaction-tag.types.test.ts", "interaction-tag.test.tsx"] + } + ] +} diff --git a/packages/agentic/components/src/components/interaction-tag/spec/tokens.yaml b/packages/agentic/components/src/components/interaction-tag/spec/tokens.yaml new file mode 100644 index 0000000000..fb6380b355 --- /dev/null +++ b/packages/agentic/components/src/components/interaction-tag/spec/tokens.yaml @@ -0,0 +1,179 @@ +schemaVersion: 1 +component: interaction-tag +implementation: interaction-tag.styles.ts + +statePrecedence: + background: + - appearance + - disabled + - pressed + - hovered + foreground: + - appearance + - disabled + divider: + - appearance + - disabled + layout: + - size + - layout + - shape + +notes: + backgroundIsPerRegion: The container paints no fill. Each action region resolves its own background from its own interaction state, so the hover and pressed fill covers exactly that region's hit target and the other region is unaffected. + foregroundIsStable: The foreground factory is configured with only an appearance level and a disabled level, so hover and pressed change the region background alone. + iconOnlyShape: getContainerRadius substitutes the circular branch for every icon-only tag, so the shape prop has no effect in that layout. + outerCornersOnly: Each action takes the resolved container radius on its outer edge through the logical corner properties and stays square on the edge that meets the divider. + dismissTargetBox: The dismiss action's resolved padding and glyph size already reach the minimum target box at both sizes; minWidth and minHeight restate that as a guarantee rather than a correction. + +bindings: + root: + static: + alignItems: stretch + alignSelf: flex-start + flexDirection: row + overflow: visible + themedBase: + borderWidth: 0 + minHeight: 24 + borderRadius: resolved from size, layout, and shape + userStyle: applied last by useInteractionTagStyles_unstable + + primaryAction: + static: + alignItems: center + flexDirection: row + flexShrink: 1 + justifyContent: center + themedBase: + minHeight: 24 + minWidth: 24 + backgroundColor: + primary: color.backgroundBrandHeavy + primaryDisabled: color.backgroundNeutralHeavyDisabled + secondary: color.backgroundNeutralSubtle + secondaryDisabled: color.backgroundNeutralSubtleDisabled + corners: container radius on the leading outer edge, zero against the divider + + dismiss: + static: + alignItems: center + flexDirection: row + flexShrink: 0 + justifyContent: center + themedBase: + minHeight: 24 + minWidth: 24 + backgroundColor: same family as primaryAction, resolved from its own state + corners: container radius on the trailing outer edge, zero against the divider + + divider: + static: + alignSelf: stretch + flexShrink: 0 + themed: + width: strokeWidth.thin + backgroundColor: + primary: color.strokeNeutralOnloud + secondary: color.strokeNeutralSubtle + disabled: color.strokeNeutralDisabled + accessible: false + hitTested: false + + focusVisual: + borderRadius: the resolved outer corners of the region it sits in + innerColor: color.strokeFocusInner + innerWidth: strokeWidth.thin + outerColor: color.strokeFocusOuter + outerWidth: strokeWidth.thick + visible: that region is focused and not disabled + instances: one per action region, each with its own testID + + content: + static: + flexShrink: 1 + textAlign: center + themed: + fontFamily: fontFamily.functional + paddingHorizontal: spacing.componentBase50 + small: + fontSize: fontSize.functionalBodySmall + fontWeight: fontWeight.functionalRegular + lineHeight: lineHeight.functionalBodySmall + medium: + fontSize: fontSize.functionalBodyMedium + fontWeight: fontWeight.functionalRegular + lineHeight: lineHeight.functionalBodyMedium + color: + primary: color.foregroundNeutralOnloud + secondary: color.foregroundNeutralPrimary + disabled: color.foregroundNeutralDisabled + + leadingIcon: + static: + flexShrink: 0 + color: resolved content color + accessible: false + size: + small: 16 + medium: 20 + + avatar: + size: + small: 16 + medium: 20 + accessible: false + note: The avatar is hidden from the accessibility tree so its image or initials do not join the primary action's name. + + dismissIcon: + static: + flexShrink: 0 + color: resolved content color + accessible: false + size: + small: 12 + medium: 16 + +layout: + small: + action: + gap: spacing.componentBase50 + paddingHorizontal: spacing.componentBase150 + paddingVertical: spacing.componentBase100 + iconAndText: + rounded: + borderRadius: borderRadius.base200 + circular: + borderRadius: borderRadius.circular + iconOnly: + borderRadius: borderRadius.circular + medium: + action: + gap: spacing.componentBase50 + paddingHorizontal: spacing.componentBase200 + paddingVertical: spacing.componentBase150 + iconAndText: + rounded: + borderRadius: borderRadius.base300 + circular: + borderRadius: borderRadius.circular + iconOnly: + borderRadius: borderRadius.circular + +tokenGaps: + - id: interaction-tag-icon-sizes + binding: leadingIcon.size, avatar.size, and dismissIcon.size + value: 12, 16, and 20 + reason: getInteractionTagIconSize returns literals because this package publishes no icon sizing scale. + - id: interaction-tag-minimum-box + binding: primaryAction and dismiss minHeight and minWidth + value: 24 + reason: The minimum target box is a literal in the style definition and is not drawn from the sizing scale. + - id: interaction-tag-uniform-action-padding + binding: primaryAction and dismiss paddingHorizontal + value: one padding pair per size across both layouts + reason: The source geometry states action padding per size only, so the icon-only layout reuses the icon-and-text padding instead of inventing a second pair. + - id: interaction-tag-state-motion + binding: primaryAction.backgroundColor and dismiss.backgroundColor + value: no transition + 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/interaction-tag/spec/usage.md b/packages/agentic/components/src/components/interaction-tag/spec/usage.md new file mode 100644 index 0000000000..145ec5481f --- /dev/null +++ b/packages/agentic/components/src/components/interaction-tag/spec/usage.md @@ -0,0 +1,121 @@ +# InteractionTag usage + +## Import + +```tsx +import { InteractionTag } from '@fluentui-react-native/components'; +``` + +## Two handlers + +Both actions are slots, so their handlers live on the slot props rather than on +the component. The dismiss action always needs a name. + +```tsx + openTeam('engineering') }} + dismiss={{ accessibilityLabel: 'Remove Engineering', onPress: () => removeTag('engineering') }} +/> +``` + +## Removing a tag + +The component never removes itself. Keep the tags in state and drop the one the +dismiss handler reports. + +```tsx +const [tags, setTags] = React.useState(['Design', 'Engineering', 'Research']); + +return ( + + {tags.map((tag) => ( + setTags((current) => current.filter((entry) => entry !== tag)), + }} + key={tag} + primaryAction={{ onPress: () => openTag(tag) }} + /> + ))} + +); +``` + +## Leading media + +Pass a leading icon for a category, or an avatar for a person. Passing both is a +type error, and at runtime the avatar wins and a development warning is logged. + +```tsx + + + +``` + +## Icon-only + +The icon-only layout drops the text and rounds the tag fully, so the primary +action needs its own name. + +```tsx + +``` + +## Appearance, size, and shape + +```tsx + + + +``` + +`shape` applies to the icon-and-text layout only; every icon-only tag is +circular. + +## Disabled + +One prop disables the whole tag. There is no way to disable one region alone. + +```tsx + +``` + +## Refs + +The top-level `ref` reaches the container. Each action slot carries its own +`ref` for the region itself. + +```tsx +const container = React.useRef(null); +const dismissButton = React.useRef(null); + +; +``` + +## Constraints + +- Keep the text short. Long text shrinks and truncates instead of wrapping, and + a tag is not a place for a sentence. +- Use InteractionTag only when both actions are real. When removal is the only + action, use [Tag](../../tag/SPEC.md); when nothing is actionable, use a + non-interactive indicator instead. +- Do not put a pressable element inside `content`. The primary action already + covers that area and a nested target is unreachable by pointer. +- Wrap a row of tags in a focus-managing container when two tab stops per tag is + too many. diff --git a/packages/agentic/components/src/components/interaction-tag/useInteractionTag.ts b/packages/agentic/components/src/components/interaction-tag/useInteractionTag.ts new file mode 100644 index 0000000000..70198bc9b3 --- /dev/null +++ b/packages/agentic/components/src/components/interaction-tag/useInteractionTag.ts @@ -0,0 +1,150 @@ +import { Pressable, View } from 'react-native'; + +import { useThemeState } from '@fluentui-react-native/design'; +import { + useAccessibilityLabelWarning, + useDevWarning, + useOptionalSlot, + usePressableState, + useSlot, +} from '@fluentui-react-native/framework-base'; + +import { hiddenFromAccessibilityProps } from '../../common/accessibility'; +import { disableNativeFocusRingProps, resolveFocusable } from '../../common/interaction'; +import type { NativeFocusPressableProps } from '../../common/interaction'; +import { semanticIconSources } from '../../common/iconSources'; +import { Icon } from '../../primitives/icon/icon'; +import { Avatar } from '../avatar/avatar'; +import { Text } from '../text/text'; +import type { InteractionTagActionProps, InteractionTagProps, InteractionTagState } from './interaction-tag.types'; + +const defaultDismissIcon = { + fontSource: semanticIconSources.dismiss, +}; + +const dividerProps = { + ...hiddenFromAccessibilityProps, + collapsable: false, + focusable: false, + pointerEvents: 'none', +} as const; + +const emptyActionProps: InteractionTagActionProps = {}; + +export function useInteractionTag_unstable(props: InteractionTagProps): InteractionTagState { + const { + appearance = 'secondary', + avatar: avatarProp, + content: contentProp, + disabled = false, + dismiss: dismissProp, + dismissIcon: dismissIconProp, + layout = 'iconAndText', + leadingIcon: leadingIconProp, + primaryAction: primaryActionProp, + ref: rootRef, + shape = 'rounded', + size = 'medium', + style: userStyle, + ...rest + } = props; + + const hasAvatar = avatarProp !== undefined && avatarProp !== null; + const hasLeadingIcon = leadingIconProp !== undefined && leadingIconProp !== null; + const hasLeadingContent = hasAvatar || hasLeadingIcon; + const hasContent = contentProp !== undefined && contentProp !== null; + const iconOnly = layout === 'iconOnly'; + + const { as: primaryAs, ref: primaryRef, ...primaryRest } = primaryActionProp ?? emptyActionProps; + const { as: dismissAs, ref: dismissRef, ...dismissRest } = dismissProp ?? emptyActionProps; + + useAccessibilityLabelWarning({ + accessibilityLabel: primaryRest.accessibilityLabel ?? primaryRest['aria-label'], + accessibilityLabelledBy: primaryRest.accessibilityLabelledBy ?? primaryRest['aria-labelledby'], + componentName: 'InteractionTag', + requireLabel: iconOnly, + warning: 'InteractionTag: icon-only tags require an accessibilityLabel on the primaryAction slot.', + }); + + useAccessibilityLabelWarning({ + accessibilityLabel: dismissRest.accessibilityLabel ?? dismissRest['aria-label'], + accessibilityLabelledBy: dismissRest.accessibilityLabelledBy ?? dismissRest['aria-labelledby'], + componentName: 'InteractionTag', + requireLabel: true, + warning: 'InteractionTag: the dismiss slot requires an accessibilityLabel that names the tag it removes.', + }); + + useDevWarning(iconOnly && !hasLeadingContent, 'InteractionTag: icon-only tags require a leading icon or an avatar.'); + useDevWarning(hasAvatar && hasLeadingIcon, 'InteractionTag: provide a leading icon or an avatar, not both. The avatar is used.'); + + const themeState = useThemeState(); + + const primaryNativeProps: NativeFocusPressableProps = { + ...primaryRest, + ...disableNativeFocusRingProps, + role: 'button', + accessibilityState: { + ...primaryRest.accessibilityState, + disabled, + }, + accessible: primaryRest.accessible ?? true, + disabled, + focusable: resolveFocusable(primaryRest.focusable, disabled), + }; + const [primaryPressableProps, primaryState] = usePressableState(primaryNativeProps); + + const dismissNativeProps: NativeFocusPressableProps = { + ...dismissRest, + ...disableNativeFocusRingProps, + role: 'button', + accessibilityState: { + ...dismissRest.accessibilityState, + disabled, + }, + accessible: dismissRest.accessible ?? true, + disabled, + focusable: resolveFocusable(dismissRest.focusable, disabled), + }; + const [dismissPressableProps, dismissState] = usePressableState(dismissNativeProps); + + const root = useSlot(View, { ...rest, ref: rootRef }); + const primaryAction = useSlot(Pressable, { ...primaryPressableProps, as: primaryAs, ref: primaryRef }); + const divider = useSlot(View, dividerProps); + const dismiss = useSlot(Pressable, { ...dismissPressableProps, as: dismissAs, ref: dismissRef }); + + const avatar = useOptionalSlot(Avatar, avatarProp, { renderByDefault: false }); + const leadingIcon = useOptionalSlot(Icon, hasAvatar ? null : leadingIconProp, { renderByDefault: false }); + const content = useOptionalSlot(Text, iconOnly ? null : contentProp, { + defaultProps: { children: 'Tag text' }, + renderByDefault: true, + }); + const dismissIcon = useOptionalSlot(Icon, dismissIconProp, { + defaultProps: defaultDismissIcon, + renderByDefault: true, + }); + + return { + root, + primaryAction, + avatar, + leadingIcon, + content, + divider, + dismiss, + dismissIcon, + disabled, + layout, + shape, + size, + dismissState, + hasAvatar, + hasContent, + hasLeadingContent, + hasLeadingIcon, + iconOnly, + primaryState, + userStyle, + ...themeState, + appearance, + }; +} diff --git a/packages/agentic/components/src/components/interaction-tag/useInteractionTagStyles.ts b/packages/agentic/components/src/components/interaction-tag/useInteractionTagStyles.ts new file mode 100644 index 0000000000..6440f6cd2b --- /dev/null +++ b/packages/agentic/components/src/components/interaction-tag/useInteractionTagStyles.ts @@ -0,0 +1,116 @@ +import type { StyleProp, TextStyle, ViewStyle } from 'react-native'; + +import { attachSlotProps } from '@fluentui-react-native/framework-base'; + +import { hiddenFromAccessibilityProps } from '../../common/accessibility'; +import { createFocusVisualProps_unstable } from '../../primitives/focus-visual/focus-visual'; +import type { FocusVisualProps } from '../../primitives/focus-visual/focus-visual.types'; + +import { + getInteractionTagActionStyle, + getInteractionTagBackgroundStyle, + getInteractionTagContainerStyle, + getInteractionTagContentStyle, + getInteractionTagDividerColorStyle, + getInteractionTagForegroundStyle, + getInteractionTagIconSize, + getInteractionTagLeadingCornerStyle, + getInteractionTagThemedStyles, + getInteractionTagTrailingCornerStyle, + interactionTagStyles, +} from './interaction-tag.styles'; +import type { InteractionTagState } from './interaction-tag.types'; + +/** + * Layers a region's outer corner radii over the focus visual rings. `createFocusVisualProps_unstable` accepts a single + * radius, and each action region rounds only the edge that does not meet the divider. + */ +function withCorners(props: FocusVisualProps, corners: ViewStyle, testID: string): FocusVisualProps { + return { + ...props, + inner: props.inner ? { ...props.inner, style: [props.inner.style, corners] } : props.inner, + style: [props.style, corners], + testID, + }; +} + +export function useInteractionTagStyles_unstable(state: InteractionTagState) { + const { disabled, dismissState, primaryState, size } = state; + const themedStyles = getInteractionTagThemedStyles(state); + const foreground = getInteractionTagForegroundStyle(state); + const containerStyle = getInteractionTagContainerStyle(state); + const actionStyle = getInteractionTagActionStyle(state); + const leadingCorners = getInteractionTagLeadingCornerStyle(state); + const trailingCorners = getInteractionTagTrailingCornerStyle(state); + const iconSizes = getInteractionTagIconSize(size); + + const rootStyle: StyleProp = [interactionTagStyles.root, containerStyle, state.userStyle]; + const primaryActionStyle: StyleProp = [ + interactionTagStyles.action, + interactionTagStyles.primaryAction, + actionStyle, + leadingCorners, + getInteractionTagBackgroundStyle(state, primaryState), + ]; + const dismissStyle: StyleProp = [ + interactionTagStyles.action, + interactionTagStyles.dismiss, + actionStyle, + trailingCorners, + getInteractionTagBackgroundStyle(state, dismissState), + ]; + const dividerStyle: StyleProp = [ + interactionTagStyles.divider, + themedStyles.divider, + getInteractionTagDividerColorStyle(state), + ]; + const contentStyle: StyleProp = [interactionTagStyles.content, getInteractionTagContentStyle(state), foreground]; + + const focusVisualOptions = { + innerColor: state.tokens.color.strokeFocusInner, + innerWidth: state.tokens.strokeWidth.thin, + outerColor: state.tokens.color.strokeFocusOuter, + outerWidth: state.tokens.strokeWidth.thick, + }; + + state.primaryFocusVisualProps = withCorners( + createFocusVisualProps_unstable({ ...focusVisualOptions, visible: primaryState.focused && !disabled }), + leadingCorners, + 'focus-visual-primary-action', + ); + state.dismissFocusVisualProps = withCorners( + createFocusVisualProps_unstable({ ...focusVisualOptions, visible: dismissState.focused && !disabled }), + trailingCorners, + 'focus-visual-dismiss', + ); + + attachSlotProps(state.root, { style: rootStyle }); + attachSlotProps(state.primaryAction, { style: primaryActionStyle }); + attachSlotProps(state.divider, { style: dividerStyle }); + attachSlotProps(state.dismiss, { style: dismissStyle }); + + if (state.avatar) { + attachSlotProps(state.avatar, { ...hiddenFromAccessibilityProps, size: iconSizes.avatar }); + } + if (state.leadingIcon) { + attachSlotProps(state.leadingIcon, { + accessible: false, + color: foreground.color, + height: iconSizes.leading, + style: interactionTagStyles.icon, + width: iconSizes.leading, + }); + } + if (state.content) { + attachSlotProps(state.content, { style: contentStyle }); + } + if (state.dismissIcon) { + attachSlotProps(state.dismissIcon, { + accessible: false, + color: foreground.color, + height: iconSizes.dismiss, + style: interactionTagStyles.icon, + width: iconSizes.dismiss, + }); + } +} diff --git a/packages/agentic/components/src/components/label/SPEC.md b/packages/agentic/components/src/components/label/SPEC.md new file mode 100644 index 0000000000..6530f91051 --- /dev/null +++ b/packages/agentic/components/src/components/label/SPEC.md @@ -0,0 +1,57 @@ +--- +name: label +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 +--- + +# Label + +## Scope + +Label is a non-interactive React Native text element that names an associated form control. It owns the label text, an optional trailing required indicator, the emphasis weight, the size scale, and the disabled foreground treatment. It does not own the associated control, the control's required or disabled semantics, field-level helper or validation text, activation forwarding, or the spacing between the label and the control it names. + +## Public contract + +`weight` defaults to `regular` and accepts `regular` or `strong`. `size` defaults to `medium` and accepts `small`, `medium`, or `large`. `required` and `disabled` default to `false`. + +`root` is a React Native `View`. `content` is a required `Text` slot that renders by default with the text `Label`; a caller supplies a string, slot props, or an `as` replacement. `requiredIndicator` is an optional `Text` slot that renders only while `required` is `true`, defaults to the text `*`, and can be replaced or suppressed by the caller. The root does not accept `children`; label text goes through `content`. The root owns the accessible name, which is read from string `content` unless the caller sets `accessibilityLabel`. + +Rendering order is `content` then `requiredIndicator`. The resolved state stores `weight`, `size`, `required`, `disabled`, theme state, and the user root style. Root style order is structural style, themed layout style, then user style. Text style order is structural style, themed typography style, then themed color style. + +`Label` builds on the package `Text` component for both text slots, so both inherit theme-aware text defaults before Label applies its own typography and color bindings. + +### Requirements + +- **LBL-001:** Expose the documented `weight`, `size`, `required`, and `disabled` axes with their defaults, and reject `children` on the root in favor of the `content` slot. +- **LBL-002:** Render `content` first and render `requiredIndicator` only while `required` is `true`, including the default label text and default asterisk indicator. +- **LBL-003:** Map weight, size, and disabled state to the documented FURN typography, foreground, and gap bindings, and apply the user root style after component styles. +- **LBL-004:** Expose the label as a single native text element whose accessible name comes from string `content` or an explicit `accessibilityLabel`, keep both text slots out of the accessibility tree, and pass `nativeID` through so an associated control can reference the label. +- **LBL-005:** Remain non-interactive: no focus target, no press or hover handling, no motion, and no disabled accessibility state of its own. + +## Platform behavior + +Label renders a React Native `View` root that is marked accessible and owns the accessible name, so it is announced once rather than as a container plus separate text runs. React Native's ARIA-aligned `role` type has no static-text role, so Label relies on its native accessible name instead of setting the legacy `accessibilityRole="text"`. The name is read from string `content` unless the caller sets `accessibilityLabel`. The root is never focusable and never receives keyboard input on either platform. + +Association is programmatic. A caller gives the label a `nativeID` and points the associated control at it with `accessibilityLabelledBy`, which is the convention used across this repository. The label itself does not know which control it names and does not change behavior when it is referenced. + +Disabled changes foreground color only. Label does not report `accessibilityState.disabled`, because the associated control owns that state and reporting it twice would announce a disabled control twice. + +## Divergences from Flex + +- `label-activation-forwarding` (`not-applicable`): the source describes browser-native forwarding of a click on the labeling element to the associated control. React Native has no equivalent implicit association, and synthesizing it would require Label to own a control reference and a focus command it cannot safely hold. Callers wire association through `nativeID` and `accessibilityLabelledBy` instead, and a caller that wants press-to-focus composes Label inside its own pressable. +- `disabled-color-transition` (`not-applicable`): the source's motion guidance is a timed foreground color transition into the disabled state. Label has no animation surface in this implementation; the disabled foreground is applied immediately, which also matches the reduced-motion guidance in the source. + +## Conformance + +| Requirement | Evidence | +| ----------- | --------------------------------------------------------------------------- | +| LBL-001 | `label.types.ts`, `useLabel.ts`, `label.types.test.ts`, `label.stories.tsx` | +| LBL-002 | `useLabel.ts`, `renderLabel.tsx`, `label.test.tsx` | +| LBL-003 | `label.styles.ts`, `useLabelStyles.ts`, `label.test.tsx` | +| LBL-004 | `useLabel.ts`, `label.test.tsx` | +| LBL-005 | `label.types.ts`, `useLabel.ts`, `label.test.tsx` | diff --git a/packages/agentic/components/src/components/label/label.stories.tsx b/packages/agentic/components/src/components/label/label.stories.tsx new file mode 100644 index 0000000000..fcb90d995b --- /dev/null +++ b/packages/agentic/components/src/components/label/label.stories.tsx @@ -0,0 +1,236 @@ +/** @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 { Input } from '../input/input'; +import { Label } from './label'; +import type { LabelSize, LabelWeight } from './label.types'; + +type StoryGroupProps = { + children: ReactNode; + label: string; +}; + +const StoryGroup = ({ children, label }: StoryGroupProps) => ( + + {label} + {children} + +); + +const weights: readonly { label: string; value: LabelWeight }[] = [ + { label: 'Regular', value: 'regular' }, + { label: 'Strong', value: 'strong' }, +]; + +const sizes: readonly { label: string; value: LabelSize }[] = [ + { label: 'Small', value: 'small' }, + { label: 'Medium', value: 'medium' }, + { label: 'Large', value: 'large' }, +]; + +const meta: Meta = { + title: 'Components/Label', + component: Label, + args: { + content: 'Display name', + disabled: false, + required: false, + size: 'medium', + testID: 'agentic-storybook-label', + weight: 'regular', + }, + argTypes: { + content: { control: 'text' }, + disabled: { control: 'boolean' }, + required: { control: 'boolean' }, + requiredIndicator: { control: false }, + size: { control: 'select', options: sizes.map(({ value }) => value) }, + weight: { control: 'select', options: weights.map(({ value }) => value) }, + }, + parameters: { + docs: { + description: { + component: + 'Label names an associated form control. It is non-interactive, offers Regular and Strong emphasis across three sizes, and can show a decorative required indicator. Associate it with a control by giving the label a nativeID and pointing the control at it with accessibilityLabelledBy.', + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + tags: ['desktop-e2e'], + parameters: { + desktopDriver: { + version: 1, + tests: [ + { + id: 'reads-as-a-single-text-element', + title: 'Exposes the label as one non-focusable text element', + steps: [ + { action: 'wait', target: { testId: 'agentic-storybook-label' } }, + { expect: { state: 'accessibleName', target: { testId: 'agentic-storybook-label' }, value: 'Display name' } }, + { expect: { state: 'enabled', target: { testId: 'agentic-storybook-label' }, value: true } }, + ], + }, + { + id: 'keeps-the-required-indicator-decorative', + title: 'Shows the required indicator without announcing it', + steps: [ + { action: 'setArgs', args: { required: true } }, + { action: 'wait', target: { testId: 'agentic-storybook-label' } }, + { expect: { state: 'accessibleName', target: { testId: 'agentic-storybook-label' }, value: 'Display name' } }, + ], + }, + ], + } satisfies DesktopStoryTests, + }, +}; + +export const Overview: Story = { + render: () => ( + + + {weights.map(({ label, value }) => ( + + + {sizes.map(({ label, value }) => ( + + + + + + + ), + parameters: { + docs: { + description: { + story: 'A grouped scan of the weight, size, required, and disabled variants.', + }, + }, + }, +}; + +export const Weight: Story = { + render: () => ( + + {weights.map(({ label, value }) => ( + + ), + parameters: { + docs: { + description: { + story: 'Regular is the default and reads at the same rank as surrounding content. Strong adds semibold emphasis.', + }, + }, + }, +}; + +export const Size: Story = { + render: () => ( + + {sizes.map(({ label, value }) => ( + + ), + parameters: { + docs: { + description: { + story: 'Small, Medium, and Large change typography only. Match the size of the associated control.', + }, + }, + }, +}; + +export const Required: Story = { + render: () => ( + + + ), + parameters: { + docs: { + description: { + story: + 'The indicator is decorative and stays out of the accessibility tree, so the associated control still has to report that it is required.', + }, + }, + }, +}; + +export const Disabled: Story = { + render: () => ( + + + ), + parameters: { + docs: { + description: { + story: 'Disabled mirrors the associated control and changes foreground color only. The indicator loses its danger color.', + }, + }, + }, +}; + +export const AssociatedControl: Story = { + render: () => ( + + + ), + parameters: { + docs: { + description: { + story: 'Give the label a nativeID and point the control at it with accessibilityLabelledBy to associate the two.', + }, + }, + }, +}; + +const styles = StyleSheet.create({ + field: { + alignItems: 'flex-start', + gap: 4, + }, + group: { + alignItems: 'flex-start', + gap: 8, + }, + groupLabel: { + fontSize: 12, + fontWeight: '600', + }, + row: { + alignItems: 'center', + flexDirection: 'row', + flexWrap: 'wrap', + gap: 12, + }, + story: { + alignItems: 'flex-start', + gap: 16, + }, +}); diff --git a/packages/agentic/components/src/components/label/label.styles.ts b/packages/agentic/components/src/components/label/label.styles.ts new file mode 100644 index 0000000000..2de54af30b --- /dev/null +++ b/packages/agentic/components/src/components/label/label.styles.ts @@ -0,0 +1,123 @@ +import { StyleSheet } from 'react-native'; +import type { TextStyle } from 'react-native'; + +import { themedStyleSheetFactory } from '@fluentui-react-native/design'; +import type { FlexTokens } from '@fluentui-react-native/design'; +import { getGapStyleValue, getThemedColorStyleFactory, getThemedStateStyleFactory } from '@fluentui-react-native/design/styling'; +import type { ColorStyleDefinition, StyleDefinition, TextColorStyle } from '@fluentui-react-native/design/styling'; + +import type { LabelState } from './label.types'; + +export const labelStyles = StyleSheet.create({ + content: { + flexShrink: 1, + }, +}); + +/** + * Structural root styling. The gap between the label text and the required indicator is uniform across sizes so the + * indicator keeps reading as part of the last word of the label. + */ +export const getLabelThemedStyles = themedStyleSheetFactory('Label.styles', ({ tokens }) => + StyleSheet.create({ + root: { + alignItems: 'center', + alignSelf: 'flex-start', + flexDirection: 'row', + gap: getGapStyleValue(tokens.spacing.componentBase50), + padding: 0, + }, + }), +); + +const sizeStateLevels = [['small', 'medium', 'large']] as const; + +const getThemedSizeStyle = getThemedStateStyleFactory( + 'Label.size', + ({ fontFamily, fontSize, lineHeight }: FlexTokens): StyleDefinition => ({ + fontFamily: fontFamily.functional, + small: { + fontSize: fontSize.functionalBodySmall, + lineHeight: lineHeight.functionalBodySmall, + }, + medium: { + fontSize: fontSize.functionalBodyMedium, + lineHeight: lineHeight.functionalBodyMedium, + }, + large: { + fontSize: fontSize.functionalBodyLarge, + lineHeight: lineHeight.functionalBodyLarge, + }, + }), + sizeStateLevels, +); + +export function getLabelSizeStyle(state: LabelState): TextStyle { + return getThemedSizeStyle(state, [state.size]); +} + +const weightStateLevels = [['regular', 'strong']] as const; + +const getThemedWeightStyle = getThemedStateStyleFactory( + 'Label.weight', + ({ fontWeight }: FlexTokens): StyleDefinition => ({ + regular: { + fontWeight: fontWeight.functionalRegular, + }, + strong: { + fontWeight: fontWeight.functionalSemibold, + }, + }), + weightStateLevels, +); + +export function getLabelWeightStyle(state: LabelState): TextStyle { + return getThemedWeightStyle(state, [state.weight]); +} + +const colorStateLevels = [['rest', 'disabled']] as const; +type ColorStateLevels = typeof colorStateLevels; + +const contentColorDefinition: ColorStyleDefinition = { + rest: { + color: 'foregroundNeutralPrimary', + }, + disabled: { + color: 'foregroundNeutralDisabled', + }, +}; + +/** + * The indicator loses its danger foreground while disabled because the whole field is unavailable. + */ +const requiredIndicatorColorDefinition: ColorStyleDefinition = { + rest: { + color: 'foregroundDangerPrimary', + }, + disabled: { + color: 'foregroundNeutralDisabled', + }, +}; + +const getThemedContentColorStyle = getThemedColorStyleFactory( + 'Label.content', + contentColorDefinition, + colorStateLevels, +); +const getThemedRequiredIndicatorColorStyle = getThemedColorStyleFactory( + 'Label.requiredIndicator', + requiredIndicatorColorDefinition, + colorStateLevels, +); + +function getColorStateSource(state: LabelState): ['rest' | 'disabled'] { + return [state.disabled ? 'disabled' : 'rest']; +} + +export function getLabelContentColorStyle(state: LabelState): TextColorStyle { + return getThemedContentColorStyle(state, getColorStateSource(state)); +} + +export function getLabelRequiredIndicatorColorStyle(state: LabelState): TextColorStyle { + return getThemedRequiredIndicatorColorStyle(state, getColorStateSource(state)); +} diff --git a/packages/agentic/components/src/components/label/label.test.tsx b/packages/agentic/components/src/components/label/label.test.tsx new file mode 100644 index 0000000000..07622dc2ca --- /dev/null +++ b/packages/agentic/components/src/components/label/label.test.tsx @@ -0,0 +1,244 @@ +/** @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 { defaultFlexTokens } from '@fluentui-react-native/design/testing'; + +import { Label } from './label'; + +const includeHidden = { includeHiddenElements: true } as const; + +function renderLabel(props: React.ComponentProps = {}) { + return render(