diff --git a/packages/@react-aria/menu/src/useMenuTrigger.ts b/packages/@react-aria/menu/src/useMenuTrigger.ts index 89f501252ac..e28b05a5aae 100644 --- a/packages/@react-aria/menu/src/useMenuTrigger.ts +++ b/packages/@react-aria/menu/src/useMenuTrigger.ts @@ -43,6 +43,7 @@ export interface MenuTriggerAria { * Provides the behavior and accessibility implementation for a menu trigger. * @param props - Props for the menu trigger. * @param state - State for the menu trigger. + * @param ref - Ref to the HTML element trigger for the menu. */ export function useMenuTrigger(props: AriaMenuTriggerProps, state: MenuTriggerState, ref: RefObject): MenuTriggerAria { let { diff --git a/packages/@react-spectrum/menu/src/context.ts b/packages/@react-spectrum/menu/src/context.ts index 623d1c630f0..cdba986d035 100644 --- a/packages/@react-spectrum/menu/src/context.ts +++ b/packages/@react-spectrum/menu/src/context.ts @@ -11,8 +11,8 @@ */ import {DOMProps, FocusStrategy, HoverEvents, KeyboardEvents, PressEvents} from '@react-types/shared'; -import {MenuTriggerState} from '@react-stately/menu'; import React, {HTMLAttributes, MutableRefObject, RefObject, useContext} from 'react'; +import {RootMenuTriggerState} from '@react-stately/menu'; import {TreeState} from '@react-stately/tree'; export interface MenuContextValue extends Omit, 'autoFocus' | 'onKeyDown'>, Pick { @@ -21,7 +21,7 @@ export interface MenuContextValue extends Omit, 'aut shouldFocusWrap?: boolean, autoFocus?: boolean | FocusStrategy, ref?: MutableRefObject, - state?: MenuTriggerState, + state?: RootMenuTriggerState, onBackButtonPress?: () => void, submenuLevel?: number } @@ -53,7 +53,7 @@ export interface MenuStateContextValue { trayContainerRef?: RefObject, menu?: RefObject, submenu?: RefObject, - rootMenuTriggerState?: MenuTriggerState + rootMenuTriggerState?: RootMenuTriggerState } export const MenuStateContext = React.createContext>(undefined); diff --git a/packages/@react-stately/combobox/package.json b/packages/@react-stately/combobox/package.json index dc1636c4c86..f27c9b6ce5d 100644 --- a/packages/@react-stately/combobox/package.json +++ b/packages/@react-stately/combobox/package.json @@ -25,7 +25,7 @@ "@react-stately/collections": "^3.10.3", "@react-stately/form": "^3.0.0", "@react-stately/list": "^3.10.1", - "@react-stately/menu": "^3.5.7", + "@react-stately/overlays": "^3.6.4", "@react-stately/select": "^3.6.0", "@react-stately/utils": "^3.9.0", "@react-types/combobox": "^3.9.0", diff --git a/packages/@react-stately/combobox/src/useComboBoxState.ts b/packages/@react-stately/combobox/src/useComboBoxState.ts index 82c4b8d2380..19c2855f89c 100644 --- a/packages/@react-stately/combobox/src/useComboBoxState.ts +++ b/packages/@react-stately/combobox/src/useComboBoxState.ts @@ -18,7 +18,7 @@ import {ListCollection, useSingleSelectListState} from '@react-stately/list'; import {SelectState} from '@react-stately/select'; import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {useControlledState} from '@react-stately/utils'; -import {useMenuTriggerState} from '@react-stately/menu'; +import {useOverlayTriggerState} from '@react-stately/overlays'; export interface ComboBoxState extends SelectState, FormValidationState{ /** The current value of the combo box input. */ @@ -27,6 +27,8 @@ export interface ComboBoxState extends SelectState, FormValidationState{ setInputValue(value: string): void, /** Selects the currently focused item and updates the input value. */ commit(): void, + /** Controls which item will be auto focused when the menu opens. */ + readonly focusStrategy: FocusStrategy, /** Opens the menu. */ open(focusStrategy?: FocusStrategy | null, trigger?: MenuTriggerAction): void, /** Toggles the menu. */ @@ -62,6 +64,7 @@ export function useComboBoxState(props: ComboBoxStateOptions(null); let onSelectionChange = (key) => { if (props.onSelectionChange) { @@ -111,8 +114,8 @@ export function useComboBoxState(props: ComboBoxStateOptions { + let triggerState = useOverlayTriggerState({...props, onOpenChange, isOpen: undefined, defaultOpen: undefined}); + let open = (focusStrategy: FocusStrategy = null, trigger?: MenuTriggerAction) => { let displayAllItems = (trigger === 'manual' || (trigger === 'focus' && menuTrigger === 'focus')); // Prevent open operations from triggering if there is nothing to display // Also prevent open operations from triggering if items are uncontrolled but defaultItems is empty, even if displayAllItems is true. @@ -124,11 +127,12 @@ export function useComboBoxState(props: ComboBoxStateOptions { + let toggle = (focusStrategy: FocusStrategy = null, trigger?: MenuTriggerAction) => { let displayAllItems = (trigger === 'manual' || (trigger === 'focus' && menuTrigger === 'focus')); // If the menu is closed and there is nothing to display, early return so toggle isn't called to prevent extraneous onOpenChange if (!(allowsEmptyCollection || filteredCollection.size > 0 || (displayAllItems && originalCollection.size > 0) || props.items) && !triggerState.isOpen) { @@ -154,12 +158,13 @@ export function useComboBoxState(props: ComboBoxStateOptions { + let toggleMenu = useCallback((focusStrategy: FocusStrategy = null) => { if (triggerState.isOpen) { updateLastCollection(); } - triggerState.toggle(focusStrategy); + setFocusStrategy(focusStrategy); + triggerState.toggle(); }, [triggerState, updateLastCollection]); let closeMenu = useCallback(() => { @@ -347,6 +352,7 @@ export function useComboBoxState(props: ComboBoxStateOptions void, + toggle(focusStrategy?: FocusStrategy | null): void +} +export interface RootMenuTriggerState extends MenuTriggerState { /** Opens a specific submenu tied to a specific menu item at a specific level. */ UNSTABLE_openSubmenu: (triggerKey: Key, level: number) => void, @@ -37,7 +36,10 @@ export interface MenuTriggerState extends OverlayTriggerState { /** An array of open submenu trigger keys within the menu tree. * The index of key within array matches the submenu level in the tree. */ - UNSTABLE_expandedKeysStack: Key[] + UNSTABLE_expandedKeysStack: Key[], + + /** Closes the menu and all submenus in the menu tree. */ + close: () => void } /** @@ -45,7 +47,7 @@ export interface MenuTriggerState extends OverlayTriggerState { * and controls which item will receive focus when it opens. Also tracks the open submenus within * the menu tree via their trigger keys. */ -export function useMenuTriggerState(props: MenuTriggerProps): MenuTriggerState { +export function useMenuTriggerState(props: MenuTriggerProps): RootMenuTriggerState { let overlayTriggerState = useOverlayTriggerState(props); let [focusStrategy, setFocusStrategy] = useState(null); let [expandedKeysStack, setExpandedKeysStack] = useState([]); diff --git a/packages/@react-stately/menu/src/useSubmenuTriggerState.ts b/packages/@react-stately/menu/src/useSubmenuTriggerState.ts index f436344129f..4ba9f4e2c06 100644 --- a/packages/@react-stately/menu/src/useSubmenuTriggerState.ts +++ b/packages/@react-stately/menu/src/useSubmenuTriggerState.ts @@ -11,8 +11,8 @@ */ import {FocusStrategy, Key} from '@react-types/shared'; -import type {MenuTriggerState} from './useMenuTriggerState'; import type {OverlayTriggerState} from '@react-stately/overlays'; +import {RootMenuTriggerState} from './useMenuTriggerState'; import {useCallback, useMemo, useState} from 'react'; export interface SubmenuTriggerProps { @@ -43,7 +43,7 @@ export interface SubmenuTriggerState extends OverlayTriggerState { * Manages state for a submenu trigger. Tracks whether the submenu is currently open, the level of the submenu, and * controls which item will receive focus when it opens. */ -export function UNSTABLE_useSubmenuTriggerState(props: SubmenuTriggerProps, state: MenuTriggerState): SubmenuTriggerState { +export function UNSTABLE_useSubmenuTriggerState(props: SubmenuTriggerProps, state: RootMenuTriggerState): SubmenuTriggerState { let {triggerKey} = props; let {UNSTABLE_expandedKeysStack, UNSTABLE_openSubmenu, UNSTABLE_closeSubmenu, close: closeAll} = state; let [submenuLevel] = useState(UNSTABLE_expandedKeysStack?.length); diff --git a/packages/@react-stately/select/package.json b/packages/@react-stately/select/package.json index 8b20b7f5390..4f1b92e4904 100644 --- a/packages/@react-stately/select/package.json +++ b/packages/@react-stately/select/package.json @@ -24,7 +24,7 @@ "dependencies": { "@react-stately/form": "^3.0.0", "@react-stately/list": "^3.10.1", - "@react-stately/menu": "^3.5.7", + "@react-stately/overlays": "^3.6.4", "@react-types/select": "^3.9.0", "@react-types/shared": "^3.22.0", "@swc/helpers": "^0.5.0" diff --git a/packages/@react-stately/select/src/useSelectState.ts b/packages/@react-stately/select/src/useSelectState.ts index 5de2f9033f8..0c19849b33b 100644 --- a/packages/@react-stately/select/src/useSelectState.ts +++ b/packages/@react-stately/select/src/useSelectState.ts @@ -10,21 +10,30 @@ * governing permissions and limitations under the License. */ -import {CollectionStateBase} from '@react-types/shared'; +import {CollectionStateBase, FocusStrategy} from '@react-types/shared'; import {FormValidationState, useFormValidationState} from '@react-stately/form'; -import {MenuTriggerState, useMenuTriggerState} from '@react-stately/menu'; +import {OverlayTriggerState, useOverlayTriggerState} from '@react-stately/overlays'; import {SelectProps} from '@react-types/select'; import {SingleSelectListState, useSingleSelectListState} from '@react-stately/list'; import {useState} from 'react'; export interface SelectStateOptions extends Omit, 'children'>, CollectionStateBase {} -export interface SelectState extends SingleSelectListState, MenuTriggerState, FormValidationState { +export interface SelectState extends SingleSelectListState, OverlayTriggerState, FormValidationState { /** Whether the select is currently focused. */ readonly isFocused: boolean, /** Sets whether the select is focused. */ - setFocused(isFocused: boolean): void + setFocused(isFocused: boolean): void, + + /** Controls which item will be auto focused when the menu opens. */ + readonly focusStrategy: FocusStrategy, + + /** Opens the menu. */ + open(focusStrategy?: FocusStrategy | null): void, + + /** Toggles the menu. */ + toggle(focusStrategy?: FocusStrategy | null): void } /** @@ -33,7 +42,8 @@ export interface SelectState extends SingleSelectListState, MenuTriggerSta * multiple selection state. */ export function useSelectState(props: SelectStateOptions): SelectState { - let triggerState = useMenuTriggerState(props); + let triggerState = useOverlayTriggerState(props); + let [focusStrategy, setFocusStrategy] = useState(null); let listState = useSingleSelectListState({ ...props, onSelectionChange: (key) => { @@ -57,15 +67,18 @@ export function useSelectState(props: SelectStateOptions): ...validationState, ...listState, ...triggerState, - open() { + focusStrategy, + open(focusStrategy: FocusStrategy = null) { // Don't open if the collection is empty. if (listState.collection.size !== 0) { + setFocusStrategy(focusStrategy); triggerState.open(); } }, - toggle(focusStrategy) { + toggle(focusStrategy: FocusStrategy = null) { if (listState.collection.size !== 0) { - triggerState.toggle(focusStrategy); + setFocusStrategy(focusStrategy); + triggerState.toggle(); } }, isFocused, diff --git a/packages/react-stately/src/index.ts b/packages/react-stately/src/index.ts index 4b939bd887f..8afbb075561 100644 --- a/packages/react-stately/src/index.ts +++ b/packages/react-stately/src/index.ts @@ -17,7 +17,7 @@ export type {DateFieldState, DateFieldStateOptions, DatePickerState, DatePickerS export type {DraggableCollectionStateOptions, DraggableCollectionState, DroppableCollectionStateOptions, DroppableCollectionState} from '@react-stately/dnd'; export type {AsyncListData, AsyncListOptions, ListData, ListOptions, TreeData, TreeOptions} from '@react-stately/data'; export type {ListProps, ListState, SingleSelectListProps, SingleSelectListState} from '@react-stately/list'; -export type {MenuTriggerProps, MenuTriggerState} from '@react-stately/menu'; +export type {MenuTriggerProps, MenuTriggerState, RootMenuTriggerState} from '@react-stately/menu'; export type {OverlayTriggerProps, OverlayTriggerState} from '@react-stately/overlays'; export type {RadioGroupProps, RadioGroupState} from '@react-stately/radio'; export type {SearchFieldProps, SearchFieldState} from '@react-stately/searchfield';