Skip to content

Commit aeac4b0

Browse files
authored
[menu] Keep state in a store (#3022)
1 parent d8d1200 commit aeac4b0

File tree

19 files changed

+554
-657
lines changed

19 files changed

+554
-657
lines changed

docs/reference/generated/menu-item.json

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,6 @@
3838
"type": "string",
3939
"detailedType": "string | undefined"
4040
},
41-
"children": {
42-
"type": "ReactNode",
43-
"detailedType": "React.ReactNode"
44-
},
4541
"className": {
4642
"type": "string | ((state: Menu.Item.State) => string | undefined)",
4743
"description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state.",

docs/reference/generated/menu-radio-item.json

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,6 @@
4343
"type": "string",
4444
"detailedType": "string | undefined"
4545
},
46-
"children": {
47-
"type": "ReactNode",
48-
"detailedType": "React.ReactNode"
49-
},
5046
"className": {
5147
"type": "string | ((state: Menu.RadioItem.State) => string | undefined)",
5248
"description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state.",

docs/reference/generated/menu-submenu-trigger.json

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,6 @@
2525
"type": "string",
2626
"detailedType": "string | undefined"
2727
},
28-
"children": {
29-
"type": "ReactNode",
30-
"detailedType": "React.ReactNode"
31-
},
3228
"className": {
3329
"type": "string | ((state: Menu.SubmenuTrigger.State) => string | undefined)",
3430
"description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state.",

packages/react/src/floating-ui-react/hooks/useListNavigation.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ export interface UseListNavigationProps {
144144
* navigating via arrow keys, specify an empty array.
145145
* @default undefined
146146
*/
147-
disabledIndices?: Array<number> | ((index: number) => boolean);
147+
disabledIndices?: ReadonlyArray<number> | ((index: number) => boolean);
148148
/**
149149
* Determines whether focus can escape the list, such that nothing is selected
150150
* after navigating beyond the boundary of the list. In some

packages/react/src/floating-ui-react/utils/composite.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { Dimensions } from '../types';
44
import { stopEvent } from './event';
55
import { ARROW_DOWN, ARROW_LEFT, ARROW_RIGHT, ARROW_UP } from './constants';
66

7-
type DisabledIndices = Array<number> | ((index: number) => boolean);
7+
type DisabledIndices = ReadonlyArray<number> | ((index: number) => boolean);
88

99
export function isDifferentGridRow(index: number, cols: number, prevRow: number) {
1010
return Math.floor(index / cols) !== prevRow;
@@ -18,7 +18,7 @@ export function isIndexOutOfListBounds(
1818
}
1919

2020
export function getMinListIndex(
21-
listRef: React.RefObject<Array<HTMLElement | null>>,
21+
listRef: React.RefObject<ReadonlyArray<HTMLElement | null>>,
2222
disabledIndices?: DisabledIndices | undefined,
2323
) {
2424
return findNonDisabledListIndex(listRef, { disabledIndices });
@@ -36,7 +36,7 @@ export function getMaxListIndex(
3636
}
3737

3838
export function findNonDisabledListIndex(
39-
listRef: React.RefObject<Array<HTMLElement | null>>,
39+
listRef: React.RefObject<ReadonlyArray<HTMLElement | null>>,
4040
{
4141
startingIndex = -1,
4242
decrement = false,
@@ -421,7 +421,7 @@ export function getGridCellIndices(
421421
}
422422

423423
export function isListIndexDisabled(
424-
listRef: React.RefObject<Array<HTMLElement | null>>,
424+
listRef: React.RefObject<ReadonlyArray<HTMLElement | null>>,
425425
index: number,
426426
disabledIndices?: DisabledIndices,
427427
) {

packages/react/src/menu/arrow/MenuArrow.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,9 @@ export const MenuArrow = React.forwardRef(function MenuArrow(
1919
) {
2020
const { className, render, ...elementProps } = componentProps;
2121

22-
const { open } = useMenuRootContext();
22+
const { store } = useMenuRootContext();
2323
const { arrowRef, side, align, arrowUncentered, arrowStyles } = useMenuPositionerContext();
24+
const open = store.useState('open');
2425

2526
const state: MenuArrow.State = React.useMemo(
2627
() => ({

packages/react/src/menu/backdrop/MenuBackdrop.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,12 @@ export const MenuBackdrop = React.forwardRef(function MenuBackdrop(
2626
) {
2727
const { className, render, ...elementProps } = componentProps;
2828

29-
const { open, mounted, transitionStatus, lastOpenChangeReason } = useMenuRootContext();
29+
const { store } = useMenuRootContext();
30+
const open = store.useState('open');
31+
const mounted = store.useState('mounted');
32+
const transitionStatus = store.useState('transitionStatus');
33+
const lastOpenChangeReason = store.useState('lastOpenChangeReason');
34+
3035
const contextMenuContext = useContextMenuRootContext();
3136

3237
const state: MenuBackdrop.State = React.useMemo(

packages/react/src/menu/checkbox-item/MenuCheckboxItem.tsx

Lines changed: 77 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -1,161 +1,114 @@
11
'use client';
22
import * as React from 'react';
3-
import { useMergedRefs } from '@base-ui-components/utils/useMergedRefs';
3+
import { useStableCallback } from '@base-ui-components/utils/useStableCallback';
44
import { useControlled } from '@base-ui-components/utils/useControlled';
5-
import { FloatingEvents, useFloatingTree } from '../../floating-ui-react';
5+
import { useFloatingTree } from '../../floating-ui-react';
66
import { MenuCheckboxItemContext } from './MenuCheckboxItemContext';
77
import { REGULAR_ITEM, useMenuItem } from '../item/useMenuItem';
88
import { useCompositeListItem } from '../../composite/list/useCompositeListItem';
99
import { useMenuRootContext } from '../root/MenuRootContext';
1010
import { useRenderElement } from '../../utils/useRenderElement';
1111
import { useBaseUiId } from '../../utils/useBaseUiId';
12-
import type { BaseUIComponentProps, HTMLProps, NonNativeButtonProps } from '../../utils/types';
12+
import type { BaseUIComponentProps, NonNativeButtonProps } from '../../utils/types';
1313
import { itemMapping } from '../utils/stateAttributesMapping';
1414
import { useMenuPositionerContext } from '../positioner/MenuPositionerContext';
1515
import { createChangeEventDetails } from '../../utils/createBaseUIEventDetails';
1616
import type { MenuRoot } from '../root/MenuRoot';
1717

18-
const InnerMenuCheckboxItem = React.memo(
19-
React.forwardRef(function InnerMenuCheckboxItem(
20-
componentProps: InnerMenuCheckboxItemProps,
21-
forwardedRef: React.ForwardedRef<Element>,
22-
) {
23-
const {
24-
checked: checkedProp,
25-
defaultChecked,
26-
onCheckedChange,
27-
className,
28-
closeOnClick,
29-
disabled = false,
30-
highlighted,
31-
id,
32-
menuEvents,
33-
itemProps,
34-
render,
35-
allowMouseUpTriggerRef,
36-
typingRef,
37-
nativeButton,
38-
nodeId,
39-
...elementProps
40-
} = componentProps;
41-
42-
const [checked, setChecked] = useControlled({
43-
controlled: checkedProp,
44-
default: defaultChecked ?? false,
45-
name: 'MenuCheckboxItem',
46-
state: 'checked',
47-
});
48-
49-
const { getItemProps, itemRef } = useMenuItem({
50-
closeOnClick,
51-
disabled,
52-
highlighted,
53-
id,
54-
menuEvents,
55-
allowMouseUpTriggerRef,
56-
typingRef,
57-
nativeButton,
58-
nodeId,
59-
itemMetadata: REGULAR_ITEM,
60-
});
61-
62-
const state: MenuCheckboxItem.State = React.useMemo(
63-
() => ({
64-
disabled,
65-
highlighted,
66-
checked,
67-
}),
68-
[disabled, highlighted, checked],
69-
);
70-
71-
const element = useRenderElement('div', componentProps, {
72-
state,
73-
stateAttributesMapping: itemMapping,
74-
props: [
75-
itemProps,
76-
{
77-
role: 'menuitemcheckbox',
78-
'aria-checked': checked,
79-
onClick(event: React.MouseEvent) {
80-
const details = createChangeEventDetails('item-press', event.nativeEvent);
81-
82-
onCheckedChange?.(!checked, details);
83-
84-
if (details.isCanceled) {
85-
return;
86-
}
87-
88-
setChecked((currentlyChecked) => !currentlyChecked);
89-
},
90-
},
91-
elementProps,
92-
getItemProps,
93-
],
94-
ref: [itemRef, forwardedRef],
95-
});
96-
97-
return (
98-
<MenuCheckboxItemContext.Provider value={state}>{element}</MenuCheckboxItemContext.Provider>
99-
);
100-
}),
101-
);
102-
10318
/**
10419
* A menu item that toggles a setting on or off.
10520
* Renders a `<div>` element.
10621
*
10722
* Documentation: [Base UI Menu](https://base-ui.com/react/components/menu)
10823
*/
10924
export const MenuCheckboxItem = React.forwardRef(function MenuCheckboxItem(
110-
props: MenuCheckboxItem.Props,
25+
componentProps: MenuCheckboxItem.Props,
11126
forwardedRef: React.ForwardedRef<Element>,
11227
) {
113-
const { id: idProp, label, closeOnClick = false, nativeButton = false, ...other } = props;
28+
const {
29+
render,
30+
className,
31+
id: idProp,
32+
label,
33+
nativeButton = false,
34+
disabled = false,
35+
closeOnClick = false,
36+
checked: checkedProp,
37+
defaultChecked,
38+
onCheckedChange,
39+
...elementProps
40+
} = componentProps;
11441

115-
const itemRef = React.useRef<HTMLElement>(null);
11642
const listItem = useCompositeListItem({ label });
117-
const mergedRef = useMergedRefs(forwardedRef, listItem.ref, itemRef);
118-
119-
const { itemProps, activeIndex, allowMouseUpTriggerRef, typingRef } = useMenuRootContext();
12043
const menuPositionerContext = useMenuPositionerContext(true);
121-
12244
const id = useBaseUiId(idProp);
123-
124-
const highlighted = listItem.index === activeIndex;
12545
const { events: menuEvents } = useFloatingTree()!;
12646

127-
// This wrapper component is used as a performance optimization.
128-
// MenuCheckboxItem reads the context and re-renders the actual MenuCheckboxItem
129-
// only when it needs to.
47+
const { store } = useMenuRootContext();
48+
const highlighted = store.useState('isActive', listItem.index);
49+
const itemProps = store.useState('itemProps');
50+
51+
const [checked, setChecked] = useControlled({
52+
controlled: checkedProp,
53+
default: defaultChecked ?? false,
54+
name: 'MenuCheckboxItem',
55+
state: 'checked',
56+
});
57+
58+
const { getItemProps, itemRef } = useMenuItem({
59+
closeOnClick,
60+
disabled,
61+
highlighted,
62+
id,
63+
menuEvents,
64+
store,
65+
nativeButton,
66+
nodeId: menuPositionerContext?.floatingContext.nodeId,
67+
itemMetadata: REGULAR_ITEM,
68+
});
69+
70+
const state: MenuCheckboxItem.State = React.useMemo(
71+
() => ({
72+
disabled,
73+
highlighted,
74+
checked,
75+
}),
76+
[disabled, highlighted, checked],
77+
);
78+
79+
const handleClick = useStableCallback((event: React.MouseEvent) => {
80+
const details = createChangeEventDetails('item-press', event.nativeEvent);
81+
82+
onCheckedChange?.(!checked, details);
83+
84+
if (details.isCanceled) {
85+
return;
86+
}
87+
88+
setChecked((currentlyChecked) => !currentlyChecked);
89+
});
90+
91+
const element = useRenderElement('div', componentProps, {
92+
state,
93+
stateAttributesMapping: itemMapping,
94+
props: [
95+
itemProps,
96+
{
97+
role: 'menuitemcheckbox',
98+
'aria-checked': checked,
99+
onClick: handleClick,
100+
},
101+
elementProps,
102+
getItemProps,
103+
],
104+
ref: [itemRef, forwardedRef, listItem.ref],
105+
});
130106

131107
return (
132-
<InnerMenuCheckboxItem
133-
{...other}
134-
id={id}
135-
ref={mergedRef}
136-
highlighted={highlighted}
137-
menuEvents={menuEvents}
138-
itemProps={itemProps}
139-
allowMouseUpTriggerRef={allowMouseUpTriggerRef}
140-
typingRef={typingRef}
141-
closeOnClick={closeOnClick}
142-
nativeButton={nativeButton}
143-
nodeId={menuPositionerContext?.floatingContext.nodeId}
144-
/>
108+
<MenuCheckboxItemContext.Provider value={state}>{element}</MenuCheckboxItemContext.Provider>
145109
);
146110
});
147111

148-
interface InnerMenuCheckboxItemProps extends MenuCheckboxItem.Props {
149-
highlighted: boolean;
150-
itemProps: HTMLProps;
151-
menuEvents: FloatingEvents;
152-
allowMouseUpTriggerRef: React.RefObject<boolean>;
153-
typingRef: React.RefObject<boolean>;
154-
closeOnClick: boolean;
155-
nativeButton: boolean;
156-
nodeId: string | undefined;
157-
}
158-
159112
export type MenuCheckboxItemState = {
160113
/**
161114
* Whether the checkbox item should ignore user interaction.

0 commit comments

Comments
 (0)