Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions docs/reference/generated/menu-item.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,6 @@
"type": "string",
"detailedType": "string | undefined"
},
"children": {
"type": "ReactNode",
"detailedType": "React.ReactNode"
},
"className": {
"type": "string | ((state: Menu.Item.State) => string | undefined)",
"description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state.",
Expand Down
4 changes: 0 additions & 4 deletions docs/reference/generated/menu-radio-item.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,6 @@
"type": "string",
"detailedType": "string | undefined"
},
"children": {
"type": "ReactNode",
"detailedType": "React.ReactNode"
},
"className": {
"type": "string | ((state: Menu.RadioItem.State) => string | undefined)",
"description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state.",
Expand Down
4 changes: 0 additions & 4 deletions docs/reference/generated/menu-submenu-trigger.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,6 @@
"type": "string",
"detailedType": "string | undefined"
},
"children": {
"type": "ReactNode",
"detailedType": "React.ReactNode"
},
"className": {
"type": "string | ((state: Menu.SubmenuTrigger.State) => string | undefined)",
"description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ export interface UseListNavigationProps {
* navigating via arrow keys, specify an empty array.
* @default undefined
*/
disabledIndices?: Array<number> | ((index: number) => boolean);
disabledIndices?: ReadonlyArray<number> | ((index: number) => boolean);
/**
* Determines whether focus can escape the list, such that nothing is selected
* after navigating beyond the boundary of the list. In some
Expand Down
8 changes: 4 additions & 4 deletions packages/react/src/floating-ui-react/utils/composite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { Dimensions } from '../types';
import { stopEvent } from './event';
import { ARROW_DOWN, ARROW_LEFT, ARROW_RIGHT, ARROW_UP } from './constants';

type DisabledIndices = Array<number> | ((index: number) => boolean);
type DisabledIndices = ReadonlyArray<number> | ((index: number) => boolean);

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

export function getMinListIndex(
listRef: React.RefObject<Array<HTMLElement | null>>,
listRef: React.RefObject<ReadonlyArray<HTMLElement | null>>,
disabledIndices?: DisabledIndices | undefined,
) {
return findNonDisabledListIndex(listRef, { disabledIndices });
Expand All @@ -36,7 +36,7 @@ export function getMaxListIndex(
}

export function findNonDisabledListIndex(
listRef: React.RefObject<Array<HTMLElement | null>>,
listRef: React.RefObject<ReadonlyArray<HTMLElement | null>>,
{
startingIndex = -1,
decrement = false,
Expand Down Expand Up @@ -421,7 +421,7 @@ export function getGridCellIndices(
}

export function isListIndexDisabled(
listRef: React.RefObject<Array<HTMLElement | null>>,
listRef: React.RefObject<ReadonlyArray<HTMLElement | null>>,
index: number,
disabledIndices?: DisabledIndices,
) {
Expand Down
3 changes: 2 additions & 1 deletion packages/react/src/menu/arrow/MenuArrow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ export const MenuArrow = React.forwardRef(function MenuArrow(
) {
const { className, render, ...elementProps } = componentProps;

const { open } = useMenuRootContext();
const { store } = useMenuRootContext();
const { arrowRef, side, align, arrowUncentered, arrowStyles } = useMenuPositionerContext();
const open = store.useState('open');

const state: MenuArrow.State = React.useMemo(
() => ({
Expand Down
7 changes: 6 additions & 1 deletion packages/react/src/menu/backdrop/MenuBackdrop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,12 @@ export const MenuBackdrop = React.forwardRef(function MenuBackdrop(
) {
const { className, render, ...elementProps } = componentProps;

const { open, mounted, transitionStatus, lastOpenChangeReason } = useMenuRootContext();
const { store } = useMenuRootContext();
const open = store.useState('open');
const mounted = store.useState('mounted');
const transitionStatus = store.useState('transitionStatus');
const lastOpenChangeReason = store.useState('lastOpenChangeReason');

const contextMenuContext = useContextMenuRootContext();

const state: MenuBackdrop.State = React.useMemo(
Expand Down
201 changes: 77 additions & 124 deletions packages/react/src/menu/checkbox-item/MenuCheckboxItem.tsx
Original file line number Diff line number Diff line change
@@ -1,161 +1,114 @@
'use client';
import * as React from 'react';
import { useMergedRefs } from '@base-ui-components/utils/useMergedRefs';
import { useStableCallback } from '@base-ui-components/utils/useStableCallback';
import { useControlled } from '@base-ui-components/utils/useControlled';
import { FloatingEvents, useFloatingTree } from '../../floating-ui-react';
import { useFloatingTree } from '../../floating-ui-react';
import { MenuCheckboxItemContext } from './MenuCheckboxItemContext';
import { REGULAR_ITEM, useMenuItem } from '../item/useMenuItem';
import { useCompositeListItem } from '../../composite/list/useCompositeListItem';
import { useMenuRootContext } from '../root/MenuRootContext';
import { useRenderElement } from '../../utils/useRenderElement';
import { useBaseUiId } from '../../utils/useBaseUiId';
import type { BaseUIComponentProps, HTMLProps, NonNativeButtonProps } from '../../utils/types';
import type { BaseUIComponentProps, NonNativeButtonProps } from '../../utils/types';
import { itemMapping } from '../utils/stateAttributesMapping';
import { useMenuPositionerContext } from '../positioner/MenuPositionerContext';
import { createChangeEventDetails } from '../../utils/createBaseUIEventDetails';
import type { MenuRoot } from '../root/MenuRoot';

const InnerMenuCheckboxItem = React.memo(
React.forwardRef(function InnerMenuCheckboxItem(
componentProps: InnerMenuCheckboxItemProps,
forwardedRef: React.ForwardedRef<Element>,
) {
const {
checked: checkedProp,
defaultChecked,
onCheckedChange,
className,
closeOnClick,
disabled = false,
highlighted,
id,
menuEvents,
itemProps,
render,
allowMouseUpTriggerRef,
typingRef,
nativeButton,
nodeId,
...elementProps
} = componentProps;

const [checked, setChecked] = useControlled({
controlled: checkedProp,
default: defaultChecked ?? false,
name: 'MenuCheckboxItem',
state: 'checked',
});

const { getItemProps, itemRef } = useMenuItem({
closeOnClick,
disabled,
highlighted,
id,
menuEvents,
allowMouseUpTriggerRef,
typingRef,
nativeButton,
nodeId,
itemMetadata: REGULAR_ITEM,
});

const state: MenuCheckboxItem.State = React.useMemo(
() => ({
disabled,
highlighted,
checked,
}),
[disabled, highlighted, checked],
);

const element = useRenderElement('div', componentProps, {
state,
stateAttributesMapping: itemMapping,
props: [
itemProps,
{
role: 'menuitemcheckbox',
'aria-checked': checked,
onClick(event: React.MouseEvent) {
const details = createChangeEventDetails('item-press', event.nativeEvent);

onCheckedChange?.(!checked, details);

if (details.isCanceled) {
return;
}

setChecked((currentlyChecked) => !currentlyChecked);
},
},
elementProps,
getItemProps,
],
ref: [itemRef, forwardedRef],
});

return (
<MenuCheckboxItemContext.Provider value={state}>{element}</MenuCheckboxItemContext.Provider>
);
}),
);

/**
* A menu item that toggles a setting on or off.
* Renders a `<div>` element.
*
* Documentation: [Base UI Menu](https://base-ui.com/react/components/menu)
*/
export const MenuCheckboxItem = React.forwardRef(function MenuCheckboxItem(
props: MenuCheckboxItem.Props,
componentProps: MenuCheckboxItem.Props,
forwardedRef: React.ForwardedRef<Element>,
) {
const { id: idProp, label, closeOnClick = false, nativeButton = false, ...other } = props;
const {
render,
className,
id: idProp,
label,
nativeButton = false,
disabled = false,
closeOnClick = false,
checked: checkedProp,
defaultChecked,
onCheckedChange,
...elementProps
} = componentProps;

const itemRef = React.useRef<HTMLElement>(null);
const listItem = useCompositeListItem({ label });
const mergedRef = useMergedRefs(forwardedRef, listItem.ref, itemRef);

const { itemProps, activeIndex, allowMouseUpTriggerRef, typingRef } = useMenuRootContext();
const menuPositionerContext = useMenuPositionerContext(true);

const id = useBaseUiId(idProp);

const highlighted = listItem.index === activeIndex;
const { events: menuEvents } = useFloatingTree()!;

// This wrapper component is used as a performance optimization.
// MenuCheckboxItem reads the context and re-renders the actual MenuCheckboxItem
// only when it needs to.
const { store } = useMenuRootContext();
const highlighted = store.useState('isActive', listItem.index);
const itemProps = store.useState('itemProps');

const [checked, setChecked] = useControlled({
controlled: checkedProp,
default: defaultChecked ?? false,
name: 'MenuCheckboxItem',
state: 'checked',
});

const { getItemProps, itemRef } = useMenuItem({
closeOnClick,
disabled,
highlighted,
id,
menuEvents,
store,
nativeButton,
nodeId: menuPositionerContext?.floatingContext.nodeId,
itemMetadata: REGULAR_ITEM,
});

const state: MenuCheckboxItem.State = React.useMemo(
() => ({
disabled,
highlighted,
checked,
}),
[disabled, highlighted, checked],
);

const handleClick = useStableCallback((event: React.MouseEvent) => {
const details = createChangeEventDetails('item-press', event.nativeEvent);

onCheckedChange?.(!checked, details);

if (details.isCanceled) {
return;
}

setChecked((currentlyChecked) => !currentlyChecked);
});

const element = useRenderElement('div', componentProps, {
state,
stateAttributesMapping: itemMapping,
props: [
itemProps,
{
role: 'menuitemcheckbox',
'aria-checked': checked,
onClick: handleClick,
},
elementProps,
getItemProps,
],
ref: [itemRef, forwardedRef, listItem.ref],
});

return (
<InnerMenuCheckboxItem
{...other}
id={id}
ref={mergedRef}
highlighted={highlighted}
menuEvents={menuEvents}
itemProps={itemProps}
allowMouseUpTriggerRef={allowMouseUpTriggerRef}
typingRef={typingRef}
closeOnClick={closeOnClick}
nativeButton={nativeButton}
nodeId={menuPositionerContext?.floatingContext.nodeId}
/>
<MenuCheckboxItemContext.Provider value={state}>{element}</MenuCheckboxItemContext.Provider>
);
});

interface InnerMenuCheckboxItemProps extends MenuCheckboxItem.Props {
highlighted: boolean;
itemProps: HTMLProps;
menuEvents: FloatingEvents;
allowMouseUpTriggerRef: React.RefObject<boolean>;
typingRef: React.RefObject<boolean>;
closeOnClick: boolean;
nativeButton: boolean;
nodeId: string | undefined;
}

export type MenuCheckboxItemState = {
/**
* Whether the checkbox item should ignore user interaction.
Expand Down
Loading
Loading