diff --git a/.changeset/dropdown-component.md b/.changeset/dropdown-component.md new file mode 100644 index 0000000000..a5035a1879 --- /dev/null +++ b/.changeset/dropdown-component.md @@ -0,0 +1,13 @@ +--- +'@fluentui-react-native/components': minor +--- + +Add the reviewed Dropdown contract and its macOS (Paper and Fabric), Windows Fabric, and Office Win32 implementation. +Dropdown draws Input's outline or underline chrome on a combobox trigger and presents ListboxItem options in a native +list popup for single or multiple selection, with controlled or uncontrolled selection and presence, a placeholder, and +a count summary when selected labels do not fit. On macOS the list requests a vertical scroller only while its options +overflow, so Paper's legacy scrollers do not inset the options. Dropdown is exported from the package root. + +ListboxItem gains a `value` prop. Inside a Dropdown list, an item with a value becomes a bound option that the list +selects, positions, and focuses, and that exposes select, add-to-selection, and remove-from-selection actions. Outside +a list, or without a value, ListboxItem is unchanged. diff --git a/.changeset/macos-popup-qualification.md b/.changeset/macos-popup-qualification.md new file mode 100644 index 0000000000..9c29982f36 --- /dev/null +++ b/.changeset/macos-popup-qualification.md @@ -0,0 +1,10 @@ +--- +'@fluentui-react-native/native-core': patch +--- + +Keep Paper Callout view creation on the main actor under strict Swift concurrency, +including Office-style builds without a React module. Add a reproducible Apple +integration probe and native popup container-focus, geometry, and expanded-state +regressions for the macOS prerequisites. Callout placement regressions first move +the owner window to the top of its screen, so a small display cannot flip the popup +to the opposite edge. diff --git a/.changeset/menu-component-set.md b/.changeset/menu-component-set.md new file mode 100644 index 0000000000..ecdbbb4fd8 --- /dev/null +++ b/.changeset/menu-component-set.md @@ -0,0 +1,14 @@ +--- +'@fluentui-react-native/components': minor +'@fluentui-react-native/native-core': minor +--- + +Add the reviewed Menu and MenuButton contracts and their macOS implementation: command rows with wrapping arrows, +Home/End, type-ahead, two submenu levels that open on arrows, press, or hover, activation dismissal, and focus return. +MenuButton adds Button chrome with a down chevron, Up/Down entry, and Tab past the trigger. Both are exported from the +package root. MenuItem gains a `submenu` prop and a Menu context that supplies +registration, type-ahead labels, and the platform's disabled-focusable default. + +The experimental modern Callout accepts `tabNavigation: 'cycle' | 'dismiss' | 'continue'` and reports `tab-forward` and +`tab-backward` dismissals. On macOS, Tab in a popup family closes its outermost Tab-exiting presentation and can continue +focus past the anchor. Legacy Callout behavior and APIs are unchanged. diff --git a/.changeset/popover-component.md b/.changeset/popover-component.md new file mode 100644 index 0000000000..7e01d6cff7 --- /dev/null +++ b/.changeset/popover-component.md @@ -0,0 +1,13 @@ +--- +'@fluentui-react-native/components': minor +'@fluentui-react-native/native-core': minor +--- + +Add the reviewed Popover contract and its macOS and Windows implementation: a Button trigger, a token-styled +surface in a native popup, controlled and uncontrolled presence with native dismissal reasons, entry focus, and +focus return to the trigger. Popover is exported from the package root. + +On macOS, the experimental modern Callout now presents an unstyled transparent popup window with a native shadow +that follows its content. Pointer input on a live view anchor no longer light-dismisses it, Tab moves within the +popup, and Paper orders modern commands after previously committed view updates. Legacy Callout behavior and APIs +are unchanged. diff --git a/.changeset/popup-focus-prerequisites.md b/.changeset/popup-focus-prerequisites.md new file mode 100644 index 0000000000..935cd46173 --- /dev/null +++ b/.changeset/popup-focus-prerequisites.md @@ -0,0 +1,17 @@ +--- +'@fluentui-react-native/components': minor +'@fluentui-react-native/framework-base': minor +'@fluentui-react-native/native-core': minor +--- + +Add opt-in disabled-focusable pressable targets and MenuItem rows that retain +disabled semantics without enabling activation. Give Input its variant-specific +focus-visible treatment: a shared outline ring or an additive underline +indicator, without a competing native editor ring. +Keep keyboard release cleanup exactly once when disabling a pressed target. + +Extend the experimental modern Callout focus operation with native first/last +strategies, report observed dismissal causes without guessing unclassified +notifications, and ignore duplicate close or late ready events for a dismissed +presentation. Rebuild native hosts for the changed modern command schema. +Legacy APIs and prebuilt Win32 commands are unchanged. diff --git a/.changeset/popup-shared-controllers.md b/.changeset/popup-shared-controllers.md new file mode 100644 index 0000000000..936841794e --- /dev/null +++ b/.changeset/popup-shared-controllers.md @@ -0,0 +1,7 @@ +--- +'@fluentui-react-native/components': patch +--- + +Share one native popup host, presence latch, and list-popup trigger controller across Popover, Menu, MenuButton, and +Dropdown, along with the popup layout styles and ref and handler composition helpers. Input and Dropdown share Input's +visual-state resolution. Behavior is unchanged. diff --git a/.changeset/win32-popover.md b/.changeset/win32-popover.md new file mode 100644 index 0000000000..4647df597b --- /dev/null +++ b/.changeset/win32-popover.md @@ -0,0 +1,17 @@ +--- +'@fluentui-react-native/components': patch +'@fluentui-react-native/native-core': minor +'@fluentui-react-native/desktop-driver': patch +--- + +Present Popover on Office Win32 through a new `Callout` adapter in native-core's `win32` entrypoint. The adapter +implements the modern props and presentation handle over the host's existing Callout registration and sends it no +new commands. It supports view anchors only, and its popup sits flush against the anchor. The host draws the surface +fill and corner radius, because its popup window has no per-pixel transparency. On Win32 the Popover trigger also +declares `Expand` and `Collapse` accessibility actions, so UI Automation exposes its expanded state. + +On Windows, the modern Callout popup window now has rounded corners and the platform popup shadow. A press on the +live view anchor no longer light-dismisses it before the anchor handles that press. + +The Windows desktop driver no longer reactivates a window whose owned popup is in the foreground. It also retries a +subtree walk when a closing popup's UI Automation provider reports a transient failure. diff --git a/.changeset/windows-win32-menu.md b/.changeset/windows-win32-menu.md new file mode 100644 index 0000000000..cf4bb6790c --- /dev/null +++ b/.changeset/windows-win32-menu.md @@ -0,0 +1,19 @@ +--- +'@fluentui-react-native/components': patch +'@fluentui-react-native/native-core': minor +'@fluentui-react-native/desktop-driver': patch +--- + +Present Menu and MenuButton on Windows and Office Win32. On Windows, the modern Callout now implements +`tabNavigation`: Tab or Shift+Tab in any popup of a nested family closes the whole family and either keeps focus at +the owner (`dismiss`) or moves it past the anchor (`continue`). Closing an outer Callout also closes the Callouts +nested in it, and focus requests treat a nested popup's owner window as active. + +Native-core's `win32` entrypoint adds a `FocusZone` adapter over the host's FocusZone registration. Its `Callout` +adapter adds `initialFocus`, keeps the popup's focus and key events from bubbling to its owner, and closes the +outermost popup on Tab. On Win32, Menu handles Home and End itself, and MenuButton returns Tab to its trigger. +Because the Win32 host reports a pointer leave together with its enter, a pending submenu change there survives the +leave until another row is hovered. + +The Windows desktop driver injects a character that one unmodified key of the foreground keyboard layout types as +that virtual key, so key-based handlers such as type-ahead match it. diff --git a/.gitattributes b/.gitattributes index ca836a9a4c..46a4c32115 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,4 +3,6 @@ *.bat text eol=crlf *.cmd text eol=crlf *.ps1 text eol=crlf -*.pbxproj -text \ No newline at end of file +*.pbxproj -text +# Unified diffs contain significant single-space context lines. +.yarn/patches/*.patch whitespace=-blank-at-eol \ No newline at end of file diff --git a/.yarn/patches/react-native-macos-npm-0.81.9-aa583a0648.patch b/.yarn/patches/react-native-macos-npm-0.81.9-aa583a0648.patch new file mode 100644 index 0000000000..19175c6890 --- /dev/null +++ b/.yarn/patches/react-native-macos-npm-0.81.9-aa583a0648.patch @@ -0,0 +1,144 @@ +diff --git a/Libraries/Text/TextInput/Singleline/RCTUITextField.mm b/Libraries/Text/TextInput/Singleline/RCTUITextField.mm +index 137d0ee9f51616ea27c397a8bf595fe2c0ff5e91..c4afef20fcdffb743f976df3b26a353f0bc69b3a 100644 +--- a/Libraries/Text/TextInput/Singleline/RCTUITextField.mm ++++ b/Libraries/Text/TextInput/Singleline/RCTUITextField.mm +@@ -391,18 +391,36 @@ + + - (BOOL)isEditable + { ++#if TARGET_OS_OSX // [macOS ++ return [super isEditable]; ++#else + return self.isEnabled; ++#endif + } + + - (void)setEditable:(BOOL)editable + { + #if TARGET_OS_OSX // [macOS +- // on macos the super must be called otherwise its NSTextFieldCell editable property doesn't get set. + [super setEditable:editable]; +-#endif // macOS] ++ self.selectable = YES; ++#else + self.enabled = editable; ++#endif // macOS] ++} ++ ++#if TARGET_OS_OSX // [macOS ++- (BOOL)acceptsFirstResponder ++{ ++ return self.isEnabled && self.isSelectable; + } + ++- (void)setAccessibilityState:(NSDictionary *)accessibilityState ++{ ++ [super setAccessibilityState:accessibilityState]; ++ self.enabled = ![accessibilityState[@"disabled"] boolValue]; ++} ++#endif // macOS] ++ + #if !TARGET_OS_OSX // [macOS] + - (void)setSecureTextEntry:(BOOL)secureTextEntry + { +diff --git a/React/Base/RCTConvert.mm b/React/Base/RCTConvert.mm +index 09822246f92767208a7d2ad561d4b2954c0fef33..6e5fce575e507ed0c3e3f6794115dd767c62c2d5 100644 +--- a/React/Base/RCTConvert.mm ++++ b/React/Base/RCTConvert.mm +@@ -1664,6 +1664,7 @@ RCT_ENUM_CONVERTER( + @"tab": NSAccessibilityButtonRole, // no exact match on macOS + @"tablist": NSAccessibilityTabGroupRole, + @"text": NSAccessibilityStaticTextRole, // also a legacy iOS accessibilityTraits ++ @"textbox": NSAccessibilityTextFieldRole, + @"timer": NSAccessibilityStaticTextRole, // no exact match on macOS + @"toolbar": NSAccessibilityToolbarRole, + // Roles/traits that are macOS specific and are used by some of the core components (Lists): +diff --git a/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm b/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm +index e7254907271b70783e886d378c7824df46e04e93..fbe1d5d7e834afb4553adb03253e34f6b9ce69d7 100644 +--- a/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm ++++ b/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm +@@ -255,6 +255,14 @@ static NSSet *returnKeyTypesSet; + + #pragma mark - RCTViewComponentView overrides + ++#if TARGET_OS_OSX // [macOS ++- (BOOL)canBecomeKeyView ++{ ++ // The backed editor, not its structural Fabric wrapper, owns the Tab stop. ++ return NO; ++} ++#endif // macOS] ++ + - (NSObject *)accessibilityElement + { + return _backedTextInputView; +@@ -305,6 +313,13 @@ static NSSet *returnKeyTypesSet; + _backedTextInputView.editable = newTextInputProps.traits.editable; + } + ++#if TARGET_OS_OSX // [macOS ++ if ([_backedTextInputView isKindOfClass:[RCTUITextField class]]) { ++ const auto state = newTextInputProps.accessibilityState.value_or(AccessibilityState{}); ++ ((RCTUITextField *)_backedTextInputView).enabled = !state.disabled; ++ } ++#endif // macOS] ++ + #if !TARGET_OS_OSX // [macOS] + if (newTextInputProps.multiline && + newTextInputProps.traits.dataDetectorTypes != oldTextInputProps.traits.dataDetectorTypes) { +diff --git a/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm b/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +index 6e20d03f64385e3729a7517c6fb8b31f6177d52d..6710864c9e9252556f7fad00caf1bbea2e80192b 100644 +--- a/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm ++++ b/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +@@ -35,6 +35,7 @@ + #endif + + #if TARGET_OS_OSX // [macOS ++#import + #import + #import + #import +@@ -489,14 +490,39 @@ const CGFloat BACKGROUND_COLOR_ZPOSITION = -1024.0f; + self.accessibilityElement.accessibilityTraits = + RCTUIAccessibilityTraitsFromAccessibilityTraits(newViewProps.accessibilityTraits); + #else // [macOS +- // On macOS, accessibilityElement returns self (NSView*) which doesn't have accessibilityTraits. +- // Set role directly on self based on traits. + RCTUIAccessibilityTraits traits = RCTUIAccessibilityTraitsFromAccessibilityTraits(newViewProps.accessibilityTraits); +- self.accessibilityRole = RCTAccessibilityRoleFromTraits(traits); +- self.accessibilityEnabled = (traits & RCTUIAccessibilityTraitNotEnabled) == 0; ++ self.accessibilityElement.accessibilityRole = RCTAccessibilityRoleFromTraits(traits); + #endif // macOS] + } + ++#if TARGET_OS_OSX // [macOS ++ if (oldViewProps.role != newViewProps.role || ++ oldViewProps.accessibilityRole != newViewProps.accessibilityRole || ++ oldViewProps.accessibilityTraits != newViewProps.accessibilityTraits) { ++ if (newViewProps.role != Role::None || !newViewProps.accessibilityRole.empty()) { ++ const bool useAriaMappings = newViewProps.role != Role::None; ++ NSString *role = RCTNSStringFromString( ++ useAriaMappings ? toString(newViewProps.role) : newViewProps.accessibilityRole); ++ self.accessibilityElement.accessibilityRole = ++ [RCTConvert accessibilityRoleFromTraits:role useAriaMappings:useAriaMappings]; ++ } else { ++ self.accessibilityElement.accessibilityRole = RCTAccessibilityRoleFromTraits( ++ RCTUIAccessibilityTraitsFromAccessibilityTraits(newViewProps.accessibilityTraits)); ++ } ++ } ++ ++ if (oldViewProps.accessibilityState != newViewProps.accessibilityState || ++ oldViewProps.accessibilityTraits != newViewProps.accessibilityTraits) { ++ const auto state = newViewProps.accessibilityState.value_or(AccessibilityState{}); ++ const auto traits = RCTUIAccessibilityTraitsFromAccessibilityTraits(newViewProps.accessibilityTraits); ++ self.accessibilityElement.accessibilityEnabled = ++ !state.disabled && (traits & RCTUIAccessibilityTraitNotEnabled) == 0; ++ self.accessibilityElement.accessibilitySelected = ++ state.selected || (traits & RCTUIAccessibilityTraitSelected) != 0; ++ self.accessibilityElement.accessibilityExpanded = state.expanded.value_or(false); ++ } ++#endif // macOS] ++ + #if !TARGET_OS_OSX // [macOS] + // `accessibilityState` + if (oldViewProps.accessibilityState != newViewProps.accessibilityState) { diff --git a/apps/storybook/AGENTS.md b/apps/storybook/AGENTS.md index ab6f495d0e..483172f146 100644 --- a/apps/storybook/AGENTS.md +++ b/apps/storybook/AGENTS.md @@ -174,7 +174,8 @@ arbitrary locations. Win32 chrome would regress addon controls and responsive behavior while increasing local maintenance. - Keep the Win32 story-pattern override in `storybook.config.mts`. It intentionally excludes ListItem and Accordion - because their Paper implementations fail-fast crash REX 0.81.1. + because their Paper implementations fail-fast crash REX 0.81.1. Remove those exclusions when they no longer crash; + all desktop endpoints must ship together. - Keep the Win32 window title distinct from the Windows Fabric title so automation never attaches to the wrong endpoint. - Use `yarn storybook smoke --win32 --mode stories` for the package-owned bundle, launch, native desktop-chrome checks, diff --git a/package.json b/package.json index d73f29e8c1..c1aad91ae2 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "morgan@npm:1.11.0": "1.12.1", "path-to-regexp@npm:8.3.0": "^8.4.2", "proxy-addr@npm:^2.0.7": "2.0.8", + "react-native-macos": "patch:react-native-macos@npm%3A0.81.9#~/.yarn/patches/react-native-macos-npm-0.81.9-aa583a0648.patch", "react-native-windows": "0.81.35", "sharp@npm:0.35.3": "0.35.5", "shell-quote": "1.11.0", diff --git a/packages/agentic/components/package.json b/packages/agentic/components/package.json index 43fca122a2..b62c347e85 100644 --- a/packages/agentic/components/package.json +++ b/packages/agentic/components/package.json @@ -90,12 +90,7 @@ } }, "furn": { - "jestPlatform": "macos", - "knip": { - "ignoreDependencies": [ - "@fluentui-react-native/native-core" - ] - } + "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..eef3ae8c3a 100644 --- a/packages/agentic/components/spec-source-report.json +++ b/packages/agentic/components/spec-source-report.json @@ -131,10 +131,14 @@ "card", "checkbox", "divider", + "dropdown", "input", "list-item", "listbox-item", + "menu", + "menu-button", "menu-item", + "popover", "progress-bar", "radio", "skeleton", @@ -154,10 +158,14 @@ "card", "checkbox", "divider", + "dropdown", "input", "list-item", "listbox-item", + "menu", + "menu-button", "menu-item", + "popover", "progress-bar", "radio", "skeleton", @@ -175,19 +183,15 @@ "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", @@ -207,19 +211,15 @@ "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", @@ -553,6 +553,15 @@ }, "candidateStatus": "current" }, + { + "component": "dropdown", + "lifecycle": "implemented", + "conformance": "reviewed", + "releaseDifferences": [], + "marketplaceDrift": null, + "originDrift": null, + "candidateStatus": "unchecked" + }, { "component": "input", "lifecycle": "implemented", @@ -611,6 +620,24 @@ }, "candidateStatus": "review-required" }, + { + "component": "menu", + "lifecycle": "implemented", + "conformance": "reviewed", + "releaseDifferences": [], + "marketplaceDrift": null, + "originDrift": null, + "candidateStatus": "unchecked" + }, + { + "component": "menu-button", + "lifecycle": "implemented", + "conformance": "reviewed", + "releaseDifferences": [], + "marketplaceDrift": null, + "originDrift": null, + "candidateStatus": "unchecked" + }, { "component": "menu-item", "lifecycle": "implemented", @@ -628,6 +655,15 @@ }, "candidateStatus": "review-required" }, + { + "component": "popover", + "lifecycle": "implemented", + "conformance": "reviewed", + "releaseDifferences": [], + "marketplaceDrift": null, + "originDrift": null, + "candidateStatus": "unchecked" + }, { "component": "progress-bar", "lifecycle": "implemented", diff --git a/packages/agentic/components/src/AGENTS.md b/packages/agentic/components/src/AGENTS.md index f324100ddc..e7a20df871 100644 --- a/packages/agentic/components/src/AGENTS.md +++ b/packages/agentic/components/src/AGENTS.md @@ -14,8 +14,12 @@ 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 native-core's public modern platform entrypoints. +- Import native-core values only from platform-resolved files (for example `common/popupHost.macos.ts` importing + `@fluentui-react-native/native-core/macos`). Shared files may use `import type` from `/macos`, whose types the + `/windows` entrypoint exports identically. Never import its root, `/legacy`, or internals, and give hosts without a + modern route a default file that fails descriptively on use. - 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`. diff --git a/packages/agentic/components/src/common/composeRefs.ts b/packages/agentic/components/src/common/composeRefs.ts new file mode 100644 index 0000000000..7691a028c2 --- /dev/null +++ b/packages/agentic/components/src/common/composeRefs.ts @@ -0,0 +1,28 @@ +import type * as React from 'react'; + +/** One ref callback that sets every given ref and clears each through its cleanup, or with null, on detach. */ +export function composeRefs(...refs: (React.Ref | undefined)[]): React.RefCallback { + return (instance: T | null) => { + const cleanups = refs.map((ref) => { + if (typeof ref === 'function') { + return ref(instance); + } + if (ref) { + (ref as React.RefObject).current = instance; + } + return undefined; + }); + return () => { + cleanups.forEach((cleanup, index) => { + const ref = refs[index]; + if (typeof cleanup === 'function') { + cleanup(); + } else if (typeof ref === 'function') { + ref(null); + } else if (ref) { + (ref as React.RefObject).current = null; + } + }); + }; + }; +} diff --git a/packages/agentic/components/src/common/desktopFocus.wdio.ts b/packages/agentic/components/src/common/desktopFocus.wdio.ts index 450cab3254..c22e292b55 100644 --- a/packages/agentic/components/src/common/desktopFocus.wdio.ts +++ b/packages/agentic/components/src/common/desktopFocus.wdio.ts @@ -29,6 +29,27 @@ export async function focusByTab(browser: WdioStoryContext['browser'], entryId: await expectNativeState(browser, targetId, 'focused', true); } +export async function clickPhysical(browser: WdioStoryContext['browser'], testId: string): Promise { + const element = await browser.$(`~${testId}`); + const rect = await browser.getElementRect(await element.elementId); + try { + await browser.performActions([ + { + type: 'pointer', + id: 'physical-click', + parameters: { pointerType: 'mouse' }, + actions: [ + { type: 'pointerMove', origin: 'viewport', x: Math.round(rect.x + rect.width / 2), y: Math.round(rect.y + rect.height / 2) }, + { type: 'pointerDown', button: 0 }, + { type: 'pointerUp', button: 0 }, + ], + }, + ]); + } finally { + await browser.releaseActions(); + } +} + export async function expectNativeState( browser: WdioStoryContext['browser'], testId: string, diff --git a/packages/agentic/components/src/common/eventHandlers.ts b/packages/agentic/components/src/common/eventHandlers.ts new file mode 100644 index 0000000000..60bf1b5606 --- /dev/null +++ b/packages/agentic/components/src/common/eventHandlers.ts @@ -0,0 +1,16 @@ +import type { GestureResponderEvent } from 'react-native'; + +/** Calls each defined handler in order with the same arguments. */ +export function composeHandlers(...handlers: (((...args: TArgs) => void) | undefined)[]) { + return (...args: TArgs) => { + for (const handler of handlers) { + handler?.(...args); + } + }; +} + +/** Whether a press came from Enter or Space rather than a pointer. */ +export function isKeyboardPress(event: GestureResponderEvent | undefined): boolean { + const native: object | undefined = event?.nativeEvent; + return native != null && ('key' in native || 'code' in native); +} diff --git a/packages/agentic/components/src/common/iconSources.ts b/packages/agentic/components/src/common/iconSources.ts index 5629bd8a0d..ed6f923a0e 100644 --- a/packages/agentic/components/src/common/iconSources.ts +++ b/packages/agentic/components/src/common/iconSources.ts @@ -3,6 +3,8 @@ import type { FontIconSource } from '../primitives/icon/icon.types'; export const semanticIconSources = { checkmark: { codepoint: 0x2713 }, chevron: { codepoint: 0x203a }, + chevronDown: { codepoint: 0x2228 }, + chevronUp: { codepoint: 0x2227 }, dismiss: { codepoint: 0x2715 }, error: { codepoint: 0x2716 }, indeterminate: { codepoint: 0x2212 }, diff --git a/packages/agentic/components/src/common/popupHost.macos.ts b/packages/agentic/components/src/common/popupHost.macos.ts new file mode 100644 index 0000000000..8d166b8de7 --- /dev/null +++ b/packages/agentic/components/src/common/popupHost.macos.ts @@ -0,0 +1,12 @@ +import { Callout, FocusZone, useNativeViewTarget } from '@fluentui-react-native/native-core/macos'; + +import type { PopupHost } from './popupHost.types'; + +export const popupHost: PopupHost | undefined = { + Callout, + ExplicitFocusCallout: Callout, + FocusZone, + useNativeViewTarget, + restoresOwnerFocus: false, + reportsPointerLeave: true, +}; diff --git a/packages/agentic/components/src/common/popupHost.ts b/packages/agentic/components/src/common/popupHost.ts new file mode 100644 index 0000000000..1c6001d1b2 --- /dev/null +++ b/packages/agentic/components/src/common/popupHost.ts @@ -0,0 +1,7 @@ +import type { PopupHost } from './popupHost.types'; + +/** + * Hosts without a native-core popup route. Mobile platforms are unsupported; popup components throw a descriptive error + * on these hosts. + */ +export const popupHost: PopupHost | undefined = undefined; diff --git a/packages/agentic/components/src/common/popupHost.types.ts b/packages/agentic/components/src/common/popupHost.types.ts new file mode 100644 index 0000000000..285826a2f5 --- /dev/null +++ b/packages/agentic/components/src/common/popupHost.types.ts @@ -0,0 +1,25 @@ +import type * as React from 'react'; +import type { Callout, CalloutProps, FocusZone, useNativeViewTarget } from '@fluentui-react-native/native-core/macos'; + +/** The native-core popup capabilities of a host with a modern popup route, and how that host behaves. */ +export interface PopupHost { + /** The Callout with the host's default entry focus. */ + Callout: typeof Callout; + /** + * The Callout for popups that request every entry focus themselves with an explicit target. Win32, whose Callout + * otherwise focuses the first content as it shows, opts out of that initial focus; other hosts use their Callout. + */ + ExplicitFocusCallout: React.FunctionComponent; + FocusZone: typeof FocusZone; + useNativeViewTarget: typeof useNativeViewTarget; + /** + * Whether closing a focused nested popup natively returns keyboard focus to the view its owner popup retained, so + * the owner sends no restore request that could land after newer keyboard navigation. + */ + restoresOwnerFocus: boolean; + /** + * Whether a view's hover-out means the pointer left it. Win32 reports a leave with the enter of the same move, so a + * pending hover change there waits for the pointer to enter another view. + */ + reportsPointerLeave: boolean; +} diff --git a/packages/agentic/components/src/common/popupHost.win32.ts b/packages/agentic/components/src/common/popupHost.win32.ts new file mode 100644 index 0000000000..240262033c --- /dev/null +++ b/packages/agentic/components/src/common/popupHost.win32.ts @@ -0,0 +1,19 @@ +import * as React from 'react'; + +import { Callout, FocusZone, useNativeViewTarget } from '@fluentui-react-native/native-core/win32'; +import type { CalloutProps } from '@fluentui-react-native/native-core/win32'; + +import type { PopupHost } from './popupHost.types'; + +function ExplicitFocusCallout(props: CalloutProps) { + return React.createElement(Callout, { ...props, initialFocus: 'none' }); +} + +export const popupHost: PopupHost | undefined = { + Callout, + ExplicitFocusCallout, + FocusZone, + useNativeViewTarget, + restoresOwnerFocus: false, + reportsPointerLeave: false, +}; diff --git a/packages/agentic/components/src/common/popupHost.windows.ts b/packages/agentic/components/src/common/popupHost.windows.ts new file mode 100644 index 0000000000..5851b03020 --- /dev/null +++ b/packages/agentic/components/src/common/popupHost.windows.ts @@ -0,0 +1,12 @@ +import { Callout, FocusZone, useNativeViewTarget } from '@fluentui-react-native/native-core/windows'; + +import type { PopupHost } from './popupHost.types'; + +export const popupHost: PopupHost | undefined = { + Callout, + ExplicitFocusCallout: Callout, + FocusZone, + useNativeViewTarget, + restoresOwnerFocus: true, + reportsPointerLeave: true, +}; diff --git a/packages/agentic/components/src/common/popupLayout.ts b/packages/agentic/components/src/common/popupLayout.ts new file mode 100644 index 0000000000..a674b9c985 --- /dev/null +++ b/packages/agentic/components/src/common/popupLayout.ts @@ -0,0 +1,28 @@ +import { StyleSheet } from 'react-native'; + +// Room for native focus rings, which draw outside a row's bounds. +const focusRingOutset = 4; + +// The widest layout a popup may take. A popup's Callout lies inside its trigger's container, or a parent popup's layout, +// which would otherwise cap the popup at that width. +const popupLayoutWidth = 4096; + +/** Layout for the content of a native popup that sizes to its widest row. */ +export const popupLayoutStyles = StyleSheet.create({ + /** Hosts a Callout in a wide, zero-height layout that does not affect the trigger's container. */ + host: { + height: 0, + left: 0, + position: 'absolute', + top: 0, + width: popupLayoutWidth, + }, + // Yoga measures a scrolling row's children at their max-content width, so the content takes its widest row's natural + // width instead of filling the window; rows then stretch to that width so trailing visuals align. + fit: { + flexDirection: 'row', + margin: -focusRingOutset, + overflow: 'scroll', + padding: focusRingOutset, + }, +}); diff --git a/packages/agentic/components/src/common/triggerExpandActions.test.ts b/packages/agentic/components/src/common/triggerExpandActions.test.ts new file mode 100644 index 0000000000..8b5e4fb508 --- /dev/null +++ b/packages/agentic/components/src/common/triggerExpandActions.test.ts @@ -0,0 +1,26 @@ +import type { AccessibilityActionEvent } from 'react-native'; + +import type { ExpandActionProps } from './triggerExpandActions'; +import { withTriggerExpandActions } from './triggerExpandActions.win32'; + +const action = (actionName: string) => ({ nativeEvent: { actionName } }) as AccessibilityActionEvent; + +describe('Win32 trigger expand actions', () => { + it('declares expand and collapse ahead of caller actions and routes them to the popup presence', () => { + const setExpanded = jest.fn(); + const onAccessibilityAction = jest.fn(); + const props = withTriggerExpandActions({ accessibilityActions: [{ name: 'activate' }], onAccessibilityAction }, setExpanded); + expect(props.accessibilityActions.map(({ name }) => name)).toEqual(['Expand', 'Collapse', 'activate']); + + props.onAccessibilityAction(action('Expand')); + props.onAccessibilityAction(action('Collapse')); + props.onAccessibilityAction(action('activate')); + expect(setExpanded.mock.calls).toEqual([[true], [false]]); + expect(onAccessibilityAction).toHaveBeenCalledTimes(3); + }); + + it('adds only the expand actions when the caller declares none', () => { + const props = withTriggerExpandActions({}, jest.fn()); + expect(props.accessibilityActions.map(({ name }) => name)).toEqual(['Expand', 'Collapse']); + }); +}); diff --git a/packages/agentic/components/src/common/triggerExpandActions.ts b/packages/agentic/components/src/common/triggerExpandActions.ts new file mode 100644 index 0000000000..0fc97c61bd --- /dev/null +++ b/packages/agentic/components/src/common/triggerExpandActions.ts @@ -0,0 +1,18 @@ +import type { AccessibilityActionEvent, AccessibilityActionInfo } from 'react-native'; + +/** Props of an element that can declare accessibility actions. */ +export interface ExpandActionProps { + accessibilityActions?: readonly AccessibilityActionInfo[]; + onAccessibilityAction?: (event: AccessibilityActionEvent) => void; +} + +/** + * Adds platform expand and collapse actions to an element that opens a popup. Only Win32 needs them to project expanded + * state. + */ +export function withTriggerExpandActions( + props: TProps, + _setExpanded: (expanded: boolean) => void, +): TProps { + return props; +} diff --git a/packages/agentic/components/src/common/triggerExpandActions.win32.ts b/packages/agentic/components/src/common/triggerExpandActions.win32.ts new file mode 100644 index 0000000000..86a24ce43a --- /dev/null +++ b/packages/agentic/components/src/common/triggerExpandActions.win32.ts @@ -0,0 +1,29 @@ +import type { AccessibilityActionEvent, AccessibilityActionInfo } from 'react-native'; + +import type { ExpandActionProps } from './triggerExpandActions'; + +// Win32 implements the UI Automation ExpandCollapse pattern, which carries `accessibilityState.expanded`, only for +// elements that declare these actions. +const expandActions: AccessibilityActionInfo[] = [ + { name: 'Expand' as AccessibilityActionInfo['name'] }, + { name: 'Collapse' as AccessibilityActionInfo['name'] }, +]; + +/** Adds the Win32 expand and collapse actions to an element that opens a popup and routes them to its presence. */ +export function withTriggerExpandActions( + props: TProps, + setExpanded: (expanded: boolean) => void, +): TProps { + const { accessibilityActions, onAccessibilityAction } = props; + return { + ...props, + accessibilityActions: accessibilityActions ? [...expandActions, ...accessibilityActions] : expandActions, + onAccessibilityAction: (event: AccessibilityActionEvent) => { + const { actionName } = event.nativeEvent; + if (actionName === 'Expand' || actionName === 'Collapse') { + setExpanded(actionName === 'Expand'); + } + onAccessibilityAction?.(event); + }, + }; +} diff --git a/packages/agentic/components/src/common/usePopupPresence.ts b/packages/agentic/components/src/common/usePopupPresence.ts new file mode 100644 index 0000000000..ca2e031aae --- /dev/null +++ b/packages/agentic/components/src/common/usePopupPresence.ts @@ -0,0 +1,91 @@ +import * as React from 'react'; + +/** The open-change reasons every popup reports: its trigger and native dismissal. */ +export type PopupOpenChangeReason = 'trigger' | 'dismiss'; + +export interface PopupPresenceOptions { + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean, data: { reason: TReason }) => void; + /** A disabled popup is never presented. */ + disabled?: boolean; + /** Whether disabling an uncontrolled popup clears its request, so enabling it again does not reopen it. */ + closeWhenDisabled?: boolean; +} + +/** The presence values callbacks read after the render that produced them. */ +export interface PopupPresenceSnapshot { + controlled: boolean; + requested: boolean; + presented: boolean; +} + +export interface PopupPresence { + /** The caller's request, or the uncontrolled one. */ + requested: boolean; + /** Whether the popup is presented: requested, enabled, and not natively dismissed. */ + presented: boolean; + /** Changes when an explicit request rearms a natively dismissed one, so native-core presents it again. */ + presentationKey: string; + latest: React.RefObject; + /** Updates an uncontrolled request and reports the change. */ + requestOpen(open: boolean, reason: TReason): void; + /** Presents again a request that native dismissal latched closed. */ + rearm(): void; + /** After native dismissal, latches a controlled request closed until it changes or is rearmed. */ + latchDismissed(): void; +} + +/** Requested presence and whether native dismissal latched the current request closed. */ +interface PresenceLifetime { + requested: boolean; + rearm: number; + latched: boolean; +} + +/** + * Requested and actual presence for a native popup. Native presence is authoritative: a native dismissal latches a + * controlled request closed until the caller changes it or an explicit trigger input rearms it. + */ +export function usePopupPresence(options: PopupPresenceOptions): PopupPresence { + const { closeWhenDisabled = false, defaultOpen = false, disabled = false, onOpenChange, open } = options; + const controlled = open !== undefined; + const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen); + const requested = controlled ? open : uncontrolledOpen; + const [rearmCount, setRearmCount] = React.useState(0); + const [lifetime, setLifetime] = React.useState(() => ({ requested, rearm: rearmCount, latched: false })); + // A new request or an explicit rearm clears the latch in the same render that presents it. + const latched = lifetime.requested === requested && lifetime.rearm === rearmCount && lifetime.latched; + if (lifetime.requested !== requested || lifetime.rearm !== rearmCount) { + setLifetime({ requested, rearm: rearmCount, latched: false }); + } + const presented = requested && !latched && !disabled; + + const latest = React.useRef({ controlled, presented, requested }); + const handleOpenChange = React.useRef(onOpenChange); + React.useLayoutEffect(() => { + latest.current = { controlled, presented, requested }; + handleOpenChange.current = onOpenChange; + }); + + React.useEffect(() => { + if (closeWhenDisabled && disabled && !controlled) { + setUncontrolledOpen(false); + } + }, [closeWhenDisabled, controlled, disabled]); + + const requestOpen = React.useCallback((next: boolean, reason: TReason) => { + if (!latest.current.controlled) { + setUncontrolledOpen(next); + } + handleOpenChange.current?.(next, { reason }); + }, []); + const rearm = React.useCallback(() => setRearmCount((value) => value + 1), []); + const latchDismissed = React.useCallback(() => { + if (latest.current.controlled) { + setLifetime((current) => ({ ...current, latched: current.requested })); + } + }, []); + + return { requested, presented, presentationKey: String(rearmCount), latest, requestOpen, rearm, latchDismissed }; +} diff --git a/packages/agentic/components/src/common/usePopupTrigger.ts b/packages/agentic/components/src/common/usePopupTrigger.ts new file mode 100644 index 0000000000..730a18e8c3 --- /dev/null +++ b/packages/agentic/components/src/common/usePopupTrigger.ts @@ -0,0 +1,208 @@ +import * as React from 'react'; +import type { GestureResponderEvent } from 'react-native'; + +import type { FocusTarget } from '@fluentui-react-native/framework-base'; +import type { CalloutDismissReason } from '@fluentui-react-native/native-core/macos'; + +import { isKeyboardPress } from './eventHandlers'; +import type { PopupOpenChangeReason, PopupPresenceOptions } from './usePopupPresence'; +import { usePopupPresence } from './usePopupPresence'; + +export interface PopupTriggerOptions extends PopupPresenceOptions< + PopupOpenChangeReason | TCloseReason +> { + /** The trigger's focus target, which regains focus when a focused popup closes. */ + triggerFocusTarget: Pick; + /** The entry focus for a pointer press; a key press, assistive expansion, or caller-driven open enters the first row. */ + pointerEntry: TEntry | 'first'; + /** Whether a native dismissal for this reason returns focus to the trigger. Escape reaches only a focused popup. */ + restoresFocusOnDismiss(reason: CalloutDismissReason): boolean; +} + +export interface PopupTrigger { + /** Whether the popup is presented: requested, enabled, and not natively dismissed. */ + presented: boolean; + /** Requested presence for the popup, which is never requested while disabled. */ + open: boolean; + presentationKey: string; + entry: TEntry | 'first'; + /** Changes whenever `entry` should be applied again to a presented popup. */ + entryKey: number; + handlePressIn(event: GestureResponderEvent): void; + handlePress(event: GestureResponderEvent): void; + /** Opens the closed popup from a trigger key with the given entry focus. */ + openFromKey(entry: TEntry | 'first'): void; + /** Applies an accessibility expand or collapse action on the trigger. */ + setExpanded(expanded: boolean): void; + /** Closes a presented popup for a completed action and returns focus to the trigger. */ + closeAndRestore(reason: TCloseReason): void; + onDismissed(reason: CalloutDismissReason): void; + /** Called when focus is confirmed anywhere in the popup. */ + onFocusHeld(): void; +} + +/** + * The trigger side of a list popup (Menu and Dropdown): presence with the native dismissal latch, the press policy, + * entry focus, and focus return to the trigger. A press that began open closes; a press that began closed opens. + */ +export function usePopupTrigger( + options: PopupTriggerOptions, +): PopupTrigger { + const { disabled = false, pointerEntry, triggerFocusTarget } = options; + const presence = usePopupPresence(options); + const { latchDismissed, latest, rearm, requestOpen, requested } = presence; + + const restoresFocusOnDismiss = React.useRef(options.restoresFocusOnDismiss); + React.useLayoutEffect(() => { + restoresFocusOnDismiss.current = options.restoresFocusOnDismiss; + }); + + const [entry, setEntry] = React.useState<{ mode: TEntry | 'first'; key: number }>({ mode: 'first', key: 0 }); + const heldFocus = React.useRef(false); + const closeCause = React.useRef(undefined); + const pressStartedOpen = React.useRef(undefined); + const [restore, setRestore] = React.useState(0); + const triggerOpened = React.useRef(false); + + const open = React.useCallback( + (mode: TEntry | 'first') => { + triggerOpened.current = true; + setEntry((current) => ({ mode, key: current.key + 1 })); + if (latest.current.requested) { + // The request is still open but natively dismissed; an explicit trigger input rearms it. + rearm(); + } + requestOpen(true, 'trigger'); + }, + [latest, rearm, requestOpen], + ); + + const closeAndRestore = React.useCallback( + (reason: TCloseReason) => { + if (!latest.current.presented) { + return; + } + closeCause.current = reason; + setRestore((value) => value + 1); + requestOpen(false, reason); + }, + [latest, requestOpen], + ); + + const onDismissed = React.useCallback( + (reason: CalloutDismissReason) => { + heldFocus.current = false; + if (!latest.current.requested) { + return; + } + closeCause.current = 'dismiss'; + latchDismissed(); + // Outside input relies on native owner restoration, so a control the input focused keeps focus. + if (restoresFocusOnDismiss.current(reason)) { + setRestore((value) => value + 1); + } + requestOpen(false, 'dismiss'); + }, + [latchDismissed, latest, requestOpen], + ); + + // A caller-driven open enters the first row; a caller-driven close of a popup that held focus returns focus to the + // trigger. + const wasRequested = React.useRef(requested); + React.useEffect(() => { + const opened = !wasRequested.current && requested; + const closed = wasRequested.current && !requested; + wasRequested.current = requested; + if (opened && !triggerOpened.current) { + setEntry((current) => ({ mode: 'first', key: current.key + 1 })); + } + triggerOpened.current = false; + if (!closed) { + return; + } + const cause = closeCause.current; + closeCause.current = undefined; + if (cause === undefined && heldFocus.current) { + setRestore((value) => value + 1); + } + heldFocus.current = false; + }, [requested]); + + React.useEffect(() => { + // A caller that keeps the popup open after a close request keeps focus in it. + if (restore === 0 || latest.current.presented) { + return undefined; + } + const request = triggerFocusTarget.requestFocus('restore'); + return () => request.cancel(); + }, [latest, restore, triggerFocusTarget]); + + const handlePressIn = React.useCallback( + (event: GestureResponderEvent) => { + pressStartedOpen.current = latest.current.presented; + // AppKit keeps the previous responder on a pointer press; focusing the trigger lets native owner restoration + // return to it when outside input closes the popup. + if (!isKeyboardPress(event)) { + triggerFocusTarget.requestFocus('pointer'); + } + }, + [latest, triggerFocusTarget], + ); + const handlePress = React.useCallback( + (event: GestureResponderEvent) => { + // Light dismissal can close the popup between press-in and press; a press that began open never reopens it. + const wasOpen = pressStartedOpen.current ?? latest.current.presented; + pressStartedOpen.current = undefined; + if (wasOpen) { + if (latest.current.presented) { + closeCause.current = 'trigger'; + requestOpen(false, 'trigger'); + } + return; + } + open(isKeyboardPress(event) ? 'first' : pointerEntry); + }, + [latest, open, pointerEntry, requestOpen], + ); + const openFromKey = React.useCallback( + (mode: TEntry | 'first') => { + if (!latest.current.presented) { + open(mode); + } + }, + [latest, open], + ); + const setExpanded = React.useCallback( + (expanded: boolean) => { + if (expanded) { + openFromKey('first'); + } else if (latest.current.presented) { + closeCause.current = 'trigger'; + // An assistive collapse can come while focus is in the popup, which then returns to the trigger. + if (heldFocus.current) { + setRestore((value) => value + 1); + } + requestOpen(false, 'trigger'); + } + }, + [latest, openFromKey, requestOpen], + ); + const onFocusHeld = React.useCallback(() => { + heldFocus.current = true; + }, []); + + return { + presented: presence.presented, + open: requested && !disabled, + presentationKey: presence.presentationKey, + entry: entry.mode, + entryKey: entry.key, + handlePressIn, + handlePress, + openFromKey, + setExpanded, + closeAndRestore, + onDismissed, + onFocusHeld, + }; +} diff --git a/packages/agentic/components/src/components/button/button.stories.tsx b/packages/agentic/components/src/components/button/button.stories.tsx index 73b73707d7..eeae742bf3 100644 --- a/packages/agentic/components/src/components/button/button.stories.tsx +++ b/packages/agentic/components/src/components/button/button.stories.tsx @@ -303,11 +303,7 @@ export const FocusManagement: Story = { await browser.releaseActions(); } }, - 'exposes the native disabled accessibility state': async ({ browser, expect, platform, skip }) => { - if (platform === 'macos') { - skip('RNmacOS 0.81.9 Fabric does not project accessibilityState.disabled to AXEnabled; native Tab exclusion is tested separately.'); - return; - } + 'exposes the native disabled accessibility state': async ({ browser, expect }) => { await expect(await browser.$('~focus-probe-disabled')).not.toBeEnabled(); }, 'releases repeated modifiers before the next Tab': async (context) => { diff --git a/packages/agentic/components/src/components/dropdown/SPEC.md b/packages/agentic/components/src/components/dropdown/SPEC.md new file mode 100644 index 0000000000..8283a13b25 --- /dev/null +++ b/packages/agentic/components/src/components/dropdown/SPEC.md @@ -0,0 +1,244 @@ +--- +name: dropdown +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 +--- + +# Dropdown + +## Scope + +Dropdown lets a user choose one value, or several, from a fixed set of +options. A field-styled trigger shows the current selection, or a placeholder, +beside a trailing chevron, and opens a popup list of ListboxItem options below +it. Single mode commits the chosen option and closes the list; multiple mode +toggles options and keeps the list open until it is dismissed. + +The trigger reuses Input's chrome, and the list is presented through +native-core's Callout and FocusZone. Dropdown owns presence, selection, option +discovery, navigation inside the list, and focus return. ListboxItem keeps the +option anatomy and takes its option semantics from Dropdown's binding +(LBI-006). + +Dropdown is not editable. It has no text entry, filtering, type-ahead, +clear-selection control, or tag display, and it never runs actions; use +MenuButton for actions. + +## Public contract + +### Props and defaults + +| Prop | Type | Default | Contract | +| -------------------------------------------- | -------------------------------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------- | +| `variant` | `'outline' \| 'underline'` | `outline` | Selects Input's outline or underline chrome. | +| `size` | `'small' \| 'medium' \| 'large'` | `medium` | Selects Input's metrics and typography, and sizes the leading icon and chevron 16, 20, and 24. | +| `disabled` | `boolean` | `false` | Disables the trigger. A disabled Dropdown never presents its list, even while `open` is true. | +| `error` | `boolean` | `false` | Selects Input's danger boundary and the primary chevron color. | +| `multiselect` | `boolean` | `false` | Selects multiple mode. | +| `selectedOptions` / `defaultSelectedOptions` | `readonly string[]` | uncontrolled, `[]` | The selected option values. | +| `onSelectedOptionsChange` | `(selectedOptions, { value }) => void` | none | Reports each user change and the option that caused it. Caller changes never call it. | +| `placeholder` | `string` | `'Select an option'` | Shown when no selected value matches an option. | +| `formatSelectedCount` | `(count: number) => string` | `` (count) => `${count} selected` `` | The multiple-mode summary shown when the selected labels do not fit. | +| `open` / `defaultOpen` | `boolean` | uncontrolled, `false` | The requested presence of the list. | +| `onOpenChange` | `(open, { reason }) => void` | none | Reports user changes with the `trigger`, `dismiss`, or `select` reason. Caller changes never call it. | +| `iconStart` | Icon slot | absent | Optional leading icon. | +| `trigger` | Pressable props with `ref` | `{}` | Props for the trigger Pressable. | +| `listbox` | View props with `ref` | `{}` | Props for the list surface. | +| `children` | React nodes | required | ListboxItem options, each with a unique string `value`. | + +The root is a View, and `ref`, `style`, and other View props apply to it. +As Input lifts naming and focus props to its editor, Dropdown lifts +`accessibilityLabel`, `accessibilityHint`, `accessibilityLabelledBy`, +`accessibilityState`, `accessible`, `focusable`, `testID`, `onFocus`, +`onBlur`, `onHoverIn`, `onHoverOut`, `onPressIn`, and `onPressOut` from the +root to the trigger. A value in `trigger` wins over a lifted value, except +that root accessibility state merges over trigger state; Dropdown then writes +its own expanded and disabled values. + +The `trigger` reason covers a trigger press, an opening key, and the expand or +collapse accessibility action. The `dismiss` reason covers Escape, Tab, +outside pointer input, and other native dismissals. The `select` reason marks +a single-mode commit. + +Each child element with a string `value` prop, directly or inside Fragments, +is an option and must render a ListboxItem with that value. Dropdown reads +values and labels from these elements, so the trigger can display a selection +while the list is closed. An option's label is its string `content`, the +string children of its `content` props, its `accessibilityLabel`, or its +value, in that order. Other children, such as section headers, render in +place and are not options. + +Dropdown warns in development for a list item without a value, which stays a +standalone item; for a repeated value, where the first option wins; and for +more than one selected value in single mode, where only the first selected +value that matches an option counts. A selected value that matches no option +stays in the selection but is not displayed. + +### Slots and anatomy + +The root View contains the trigger and the popup host. The trigger contains +the optional leading icon, the value text, and the chevron, in that order, +under Input's focus indicator. The popup host takes no layout space. The list +surface it presents holds a scroll container and a focus zone around the +children. + +| Slot | Rendered when | Contract | +| ----------- | ----------------------- | ------------------------------------------------------------------ | +| `root` | always | Structural, inaccessible View with the caller `ref` and style. | +| `trigger` | always | The accessible combobox Pressable and the Dropdown's focus target. | +| `iconStart` | `iconStart` is supplied | Input's leading icon slot, inaccessible. | +| `listbox` | the list is presented | The list surface. Its `ref` is null while the list is closed. | + +The value text, chevron, focus indicator, popup host, scroll container, and +focus zone are private. + +### Requirements + +- **DRP-001:** Resolve the documented defaults on a structural, inaccessible + root View that holds the trigger Pressable and the popup host, with the + caller `ref` on the root and caller styles last. Lift the documented root + props to the trigger, letting `trigger` values win and merging accessibility + state, and warn in development when the trigger has neither + `accessibilityLabel` nor `accessibilityLabelledBy`. +- **DRP-002:** Draw the trigger with Input's chrome for the resolved variant + and size, with the state precedence disabled, error, focused, pressed, + hovered, rest. The trigger counts as focused while it holds focus or the list + is presented, and shows Input's style-specific focus indicator only under + keyboard modality. +- **DRP-003:** Render the private chevron after the value at 16, 20, and 24 + for small, medium, and large, pointing down while closed and up while the + list is presented. Color it disabled when disabled, primary in error or while + the focus indicator shows, and secondary otherwise. +- **DRP-004:** Show the selected options' labels in option order, separated by + commas, on one line with tail truncation, or the placeholder in Input's + placeholder color when nothing displayable is selected. In multiple mode, + show `formatSelectedCount` for the number of displayed options when the + labels do not fit. The display follows every selection change, including + while the list is open. +- **DRP-005:** Discover options as documented and own their selection through + `selectedOptions`, `defaultSelectedOptions`, and `onSelectedOptionsChange`. + Activating an enabled option in single mode commits it, reporting a change + only when the selection differs, and closes the list with `select`. In + multiple mode, activation toggles the option and keeps the list open. A + controlled Dropdown displays only the selection it receives, and a + single-mode commit closes the list either way. Issue the documented + development warnings. +- **DRP-006:** Keep requested presence controllable in both directions with + Popover's latch: a native dismissal reports `dismiss` and keeps the list + closed while requested presence stays true, until it becomes false and then + true or the trigger opens it again. A trigger press while the list is open + closes it without reopening. A disabled Dropdown never presents its list, and + disabling an open Dropdown closes it. Closed options are absent from the + accessibility tree and tab order. +- **DRP-007:** A trigger press, Enter, Space, Down, Up, or the expand action + opens the closed list and reports `trigger`. After native readiness, focus + moves to the entry option: the enabled selected option in single mode, or in + multiple mode the most recently toggled option that is still selected and + enabled, else the first enabled selected option. Without an entry option, + focus moves to the first enabled option, or the last for Up. A caller-driven + open uses the same entry as Enter. When no option is enabled, the list + surface takes focus. Confirm each focus request through its native outcome. +- **DRP-008:** While the list is open, Down and Up move focus between enabled + options and stop at the ends; Home and End focus the first and last enabled + options; and Enter, Space, or a pointer press activates the focused option + (DRP-005). Hovering an enabled option focuses it while the list holds focus. + Disabled options are never focus stops and refuse activation. +- **DRP-009:** A single-mode commit, Escape, Shift+Tab, and a caller close + while the list holds focus close the list and return focus to the trigger; + Escape never undoes multiple-mode toggles. Tab closes the list and moves + focus to the next focusable view after the trigger through native-core + continuation, except on Win32 (R1). Outside pointer input closes the list and + returns focus to the trigger unless the input focused another control. + Application deactivation and anchor loss close the list without moving focus. +- **DRP-010:** Place the list below the trigger, start-aligned and offset by + the token gap, and rely on native placement to flip, mirror for + right-to-left layout, and clamp to the work area. Resolve the surface fill, + stroke, and radius from Popover's bindings with reduced padding. Make the + surface at least as wide as the trigger and as wide as its widest option, cap + the option area at a height of 320 with internal scrolling, keep the focused + option scrolled into view, apply native elevation, and apply caller + `listbox` styles last. +- **DRP-011:** Expose the trigger as a combobox with its name, the expanded + state from actual presence, the disabled state, and a value that lists every + selected label even while the count summary shows. Handle the expand and + collapse actions. Expose the list surface with the `list` role, a name that + defaults to the trigger's string label, and the multiselectable and required + states. Expose each option as LBI-006 describes, with the documented + outcomes for the select, add, and remove selection actions. +- **DRP-012:** Present through native-core's modern Callout and FocusZone on + macOS (Paper and Fabric) and Windows Fabric, and through its Win32 Callout + and FocusZone adapters on Office Win32. Other hosts throw a descriptive error + on use. Do not export Dropdown publicly until it qualifies on all four + desktop endpoints. + +## Platform behavior + +The list is a separate native popup window owned by the trigger's window. +Native light dismissal is authoritative; Dropdown then reconciles presence and +focus. The list uses Callout `tabNavigation: 'continue'`. On macOS and Windows +Fabric, Tab from the list closes it and focuses the view that follows the +trigger in the owner window's key-view order, and Shift+Tab closes it and +focuses the trigger. + +Windows Fabric's FocusZone handles Up, Down, Home, and End natively. On macOS +the FocusZone handles Up and Down, and Dropdown handles Home and End through +the options' key events. The trigger handles the platform's expand and +collapse accessibility actions by setting the requested presence. + +Office Win32 presents the list through native-core's Win32 Callout adapter +without initial focus, so Dropdown focuses the entry option itself. The host's +FocusZone handles Up and Down, and Dropdown handles Home and End. The adapter +brings Popover's host differences: + +- The list sits flush against the trigger, because the host ignores the gap. +- The host window draws the surface fill and rounded corners. +- Tab and Shift+Tab close the list and focus the trigger, because the host + cannot continue focus past the popup's owner (R1). +- The trigger declares the expand and collapse actions, because Win32 UI + Automation exposes ExpandCollapse only for an element that declares them. + +On macOS the list never requests a horizontal scroller and requests a vertical +scroller only while its options overflow, because Paper reserves the space of +legacy scrollers even while they are hidden. iOS and Android are unsupported. + +## Divergences from Flex + +| ID | Disposition | React Native contract | Follow-up | +| --------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | +| `dropdown-real-option-focus` | Accepted | Options take real native focus, and the trigger keeps its focused chrome while the list is presented, because the renderers have no active-descendant transport (D09). | Revisit if the renderers add active-descendant support. | +| `dropdown-combobox-relationships` | Accepted | Expanded state and value replace the has-popup, controls, and active-descendant relationships, which have no typed property; the list name defaults to the trigger label. | Adopt native relationships if the renderers add them. | +| `dropdown-list-roles` | Accepted | The list and options use the `list` and `listitem` roles with selection state, which UI Automation maps to the Selection and SelectionItem patterns. | Adopt listbox and option roles if the renderers add them. | +| `dropdown-invalid-state` | Accepted | `error` changes only the visuals, because Pressable does not carry an invalid state on any renderer. Callers reference the error text from the hint. | Report invalid state when a renderer transports it. | +| `dropdown-tab-exit` | Accepted | Shift+Tab from the list focuses the trigger rather than the view before it, and on Win32 Tab also focuses the trigger (R1). | Continue past the trigger when native-core supports it. | +| `dropdown-trigger-width` | Accepted | The trigger stretches to its container like Input rather than fitting its content; callers constrain the width with style. | None. | +| `dropdown-chevron-glyph` | Accepted | The chevron uses the package's shared down and up chevron glyphs rather than the Fluent ChevronDown and ChevronUp assets. | Replace when the package adopts the Fluent icon font. | +| `dropdown-native-elevation` | Accepted | The native popup window shadow provides elevation instead of drawing `shadow.low`, as for Popover. | Revisit if native-core supports inset shadow geometry. | +| `dropdown-win32-gap` | Accepted | On Win32 the list sits flush against the trigger, because the Office host's popup ignores the requested gap. | Pass the gap through when the host honors it. | +| `dropdown-token-strip` | Deferred | Multiple mode shows comma-separated labels or the count summary; the optional tag-strip display is not offered. | Add with a reviewed Tag-based design. | +| `dropdown-motion` | Deferred | Presentation, chevron, and trigger changes are instant, which equals the reduced-motion behavior. | Revisit when Flex motion is final. | + +## Conformance + +The listed files exist for every requirement. macOS Fabric and Paper, Windows +Fabric, and the Win32 adapters qualify the native behavior in the Dropdown +stories. + +| Requirement | Evidence | +| ----------- | ------------------------------------------------------------------------------------------ | +| DRP-001 | `dropdown.types.ts`, `useDropdown.ts`, `dropdown.test.tsx`, `dropdown.types.test.ts` | +| DRP-002 | `dropdown.styles.ts`, `useDropdownStyles.ts`, `renderDropdown.tsx`, `dropdown.test.tsx` | +| DRP-003 | `useDropdown.ts`, `dropdown.styles.ts`, `renderDropdown.tsx`, `dropdown.test.tsx` | +| DRP-004 | `dropdownOptions.ts`, `renderDropdown.tsx`, `dropdown.test.tsx`, `dropdown.stories.tsx` | +| DRP-005 | `dropdownOptions.ts`, `dropdownController.ts`, `dropdown.test.tsx`, `dropdown.stories.tsx` | +| DRP-006 | `dropdownController.ts`, `dropdownPopup.tsx`, `dropdown.test.tsx`, `dropdown.stories.tsx` | +| DRP-007 | `dropdownController.ts`, `dropdownPopup.tsx`, `dropdown.test.tsx`, `dropdown.stories.tsx` | +| DRP-008 | `dropdownPopup.tsx`, `dropdown.test.tsx`, `dropdown.stories.tsx` | +| DRP-009 | `dropdownController.ts`, `dropdownPopup.tsx`, `dropdown.test.tsx`, `dropdown.stories.tsx` | +| DRP-010 | `dropdown.styles.ts`, `dropdownPopup.tsx`, `dropdown.test.tsx`, `dropdown.stories.tsx` | +| DRP-011 | `useDropdown.ts`, `dropdownPopup.tsx`, `dropdown.test.tsx`, `dropdown.stories.tsx` | +| DRP-012 | `dropdownController.ts`, `dropdown.test.tsx`, `dropdown.stories.tsx` | diff --git a/packages/agentic/components/src/components/dropdown/dropdown.stories.tsx b/packages/agentic/components/src/components/dropdown/dropdown.stories.tsx new file mode 100644 index 0000000000..b04a1d0ebe --- /dev/null +++ b/packages/agentic/components/src/components/dropdown/dropdown.stories.tsx @@ -0,0 +1,371 @@ +/** @jsxImportSource @fluentui-react-native/framework-base */ +import * as React from 'react'; +import { StyleSheet, View } from 'react-native'; + +import type { Meta, StoryObj } from '@storybook/react-native'; +import type { WdioStory } from '@fluentui-react-native/storybook-desktop/testing'; + +import { StoryStatus } from '../../common/StoryStatus.story-helpers'; +import { Button } from '../button/button'; +import { Input } from '../input/input'; +import { ListboxItem } from '../listbox-item/listbox-item'; +import { Dropdown } from './dropdown'; +import type { DropdownOpenChangeData, DropdownSize, DropdownVariant } from './dropdown.types'; + +type SceneProps = { + variant?: DropdownVariant; + size?: DropdownSize; + disabled?: boolean; + error?: boolean; + multiselect?: boolean; + placeholder?: string; +}; + +const fruits = ['Apple', 'Banana', 'Cherry', 'Grape', 'Lemon', 'Mango']; +const unavailable = new Set(['Cherry']); + +const cities = [ + 'Amsterdam', + 'Berlin', + 'Cairo', + 'Delhi', + 'Edinburgh', + 'Florence', + 'Geneva', + 'Havana', + 'Istanbul', + 'Jakarta', + 'Kyoto', + 'Lima', + 'Madrid', + 'Nairobi', + 'Oslo', + 'Paris', + 'Quito', + 'Rome', + 'Seoul', + 'Tokyo', +]; + +const valueOf = (label: string) => label.toLowerCase(); +const labelOf = (value: string) => value.charAt(0).toUpperCase() + value.slice(1); + +/** A scene that reports presence, selection, and focused option through status rows. */ +function FruitScene({ multiselect, ...props }: SceneProps) { + const [reason, setReason] = React.useState('None'); + const [selection, setSelection] = React.useState('None'); + const [focus, setFocus] = React.useState('None'); + const onOpenChange = (open: boolean, { reason: cause }: DropdownOpenChangeData) => { + setReason(`${open ? 'Opened' : 'Closed'}: ${cause}`); + if (open) setFocus('None'); + }; + return ( + + + setSelection(selected.length ? selected.map(labelOf).join(', ') : 'None')} + trigger={{ testID: 'dropdown-trigger' }} + listbox={{ testID: 'dropdown-list' }} + style={styles.field} + > + {fruits.map((fruit) => ( + setFocus(fruit)} + /> + ))} + +