Skip to content

Commit

Permalink
Separate menutrigger state (#5597)
Browse files Browse the repository at this point in the history
* Separate types for submenu trigger and root menu trigger states
  • Loading branch information
snowystinger committed Dec 19, 2023
1 parent 3900ead commit 8ad0c43
Show file tree
Hide file tree
Showing 10 changed files with 52 additions and 30 deletions.
1 change: 1 addition & 0 deletions packages/@react-aria/menu/src/useMenuTrigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export interface MenuTriggerAria<T> {
* 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<T>(props: AriaMenuTriggerProps, state: MenuTriggerState, ref: RefObject<Element>): MenuTriggerAria<T> {
let {
Expand Down
6 changes: 3 additions & 3 deletions packages/@react-spectrum/menu/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLAttributes<HTMLElement>, 'autoFocus' | 'onKeyDown'>, Pick<KeyboardEvents, 'onKeyDown'> {
Expand All @@ -21,7 +21,7 @@ export interface MenuContextValue extends Omit<HTMLAttributes<HTMLElement>, 'aut
shouldFocusWrap?: boolean,
autoFocus?: boolean | FocusStrategy,
ref?: MutableRefObject<HTMLDivElement>,
state?: MenuTriggerState,
state?: RootMenuTriggerState,
onBackButtonPress?: () => void,
submenuLevel?: number
}
Expand Down Expand Up @@ -53,7 +53,7 @@ export interface MenuStateContextValue<T> {
trayContainerRef?: RefObject<HTMLElement>,
menu?: RefObject<HTMLDivElement>,
submenu?: RefObject<HTMLDivElement>,
rootMenuTriggerState?: MenuTriggerState
rootMenuTriggerState?: RootMenuTriggerState
}

export const MenuStateContext = React.createContext<MenuStateContextValue<any>>(undefined);
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-stately/combobox/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 13 additions & 7 deletions packages/@react-stately/combobox/src/useComboBoxState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> extends SelectState<T>, FormValidationState{
/** The current value of the combo box input. */
Expand All @@ -27,6 +27,8 @@ export interface ComboBoxState<T> extends SelectState<T>, 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. */
Expand Down Expand Up @@ -62,6 +64,7 @@ export function useComboBoxState<T extends object>(props: ComboBoxStateOptions<T

let [showAllItems, setShowAllItems] = useState(false);
let [isFocused, setFocusedState] = useState(false);
let [focusStrategy, setFocusStrategy] = useState<FocusStrategy>(null);

let onSelectionChange = (key) => {
if (props.onSelectionChange) {
Expand Down Expand Up @@ -111,8 +114,8 @@ export function useComboBoxState<T extends object>(props: ComboBoxStateOptions<T
}
};

let triggerState = useMenuTriggerState({...props, onOpenChange, isOpen: undefined, defaultOpen: undefined});
let open = (focusStrategy?: FocusStrategy, trigger?: MenuTriggerAction) => {
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.
Expand All @@ -124,11 +127,12 @@ export function useComboBoxState<T extends object>(props: ComboBoxStateOptions<T
}

menuOpenTrigger.current = trigger;
triggerState.open(focusStrategy);
setFocusStrategy(focusStrategy);
triggerState.open();
}
};

let toggle = (focusStrategy?: FocusStrategy, trigger?: MenuTriggerAction) => {
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) {
Expand All @@ -154,12 +158,13 @@ export function useComboBoxState<T extends object>(props: ComboBoxStateOptions<T

// If menu is going to close, save the current collection so we can freeze the displayed collection when the
// user clicks outside the popover to close the menu. Prevents the menu contents from updating as the menu closes.
let toggleMenu = useCallback((focusStrategy) => {
let toggleMenu = useCallback((focusStrategy: FocusStrategy = null) => {
if (triggerState.isOpen) {
updateLastCollection();
}

triggerState.toggle(focusStrategy);
setFocusStrategy(focusStrategy);
triggerState.toggle();
}, [triggerState, updateLastCollection]);

let closeMenu = useCallback(() => {
Expand Down Expand Up @@ -347,6 +352,7 @@ export function useComboBoxState<T extends object>(props: ComboBoxStateOptions<T
return {
...validation,
...triggerState,
focusStrategy,
toggle,
open,
close: commitValue,
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-stately/menu/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,5 @@ export {useMenuTriggerState} from './useMenuTriggerState';
export {UNSTABLE_useSubmenuTriggerState} from './useSubmenuTriggerState';

export type {MenuTriggerProps} from '@react-types/menu';
export type {MenuTriggerState} from './useMenuTriggerState';
export type {MenuTriggerState, RootMenuTriggerState} from './useMenuTriggerState';
export type {SubmenuTriggerProps, SubmenuTriggerState} from './useSubmenuTriggerState';
14 changes: 8 additions & 6 deletions packages/@react-stately/menu/src/useMenuTriggerState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,10 @@ export interface MenuTriggerState extends OverlayTriggerState {
open(focusStrategy?: FocusStrategy | null): void,

/** Toggles the menu. */
toggle(focusStrategy?: FocusStrategy | null): void,

/** Closes the menu and all submenus in the menu tree. */
close: () => 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,

Expand All @@ -37,15 +36,18 @@ 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
}

/**
* Manages state for a menu trigger. Tracks whether the menu is currently open,
* 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<FocusStrategy>(null);
let [expandedKeysStack, setExpandedKeysStack] = useState<Key[]>([]);
Expand Down
4 changes: 2 additions & 2 deletions packages/@react-stately/menu/src/useSubmenuTriggerState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-stately/select/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
29 changes: 21 additions & 8 deletions packages/@react-stately/select/src/useSelectState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> extends Omit<SelectProps<T>, 'children'>, CollectionStateBase<T> {}

export interface SelectState<T> extends SingleSelectListState<T>, MenuTriggerState, FormValidationState {
export interface SelectState<T> extends SingleSelectListState<T>, 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
}

/**
Expand All @@ -33,7 +42,8 @@ export interface SelectState<T> extends SingleSelectListState<T>, MenuTriggerSta
* multiple selection state.
*/
export function useSelectState<T extends object>(props: SelectStateOptions<T>): SelectState<T> {
let triggerState = useMenuTriggerState(props);
let triggerState = useOverlayTriggerState(props);
let [focusStrategy, setFocusStrategy] = useState<FocusStrategy>(null);
let listState = useSingleSelectListState({
...props,
onSelectionChange: (key) => {
Expand All @@ -57,15 +67,18 @@ export function useSelectState<T extends object>(props: SelectStateOptions<T>):
...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,
Expand Down
2 changes: 1 addition & 1 deletion packages/react-stately/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down

1 comment on commit 8ad0c43

@rspbot
Copy link

@rspbot rspbot commented on 8ad0c43 Dec 19, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.