diff --git a/docs/data/api/select-trigger.json b/docs/data/api/select-trigger.json index 92239a7615..f816e4ed5e 100644 --- a/docs/data/api/select-trigger.json +++ b/docs/data/api/select-trigger.json @@ -11,7 +11,10 @@ "import { Select } from '@base_ui/react/Select';\nconst SelectTrigger = Select.Trigger;" ], "classes": [], + "spread": true, + "themeDefaultProps": true, "muiName": "SelectTrigger", + "forwardsRefTo": "HTMLDivElement", "filename": "/packages/mui-base/src/Select/Trigger/SelectTrigger.tsx", "inheritance": null, "demos": "", diff --git a/packages/mui-base/src/Menu/Trigger/MenuTrigger.test.tsx b/packages/mui-base/src/Menu/Trigger/MenuTrigger.test.tsx index 836cb1488e..3c199cc104 100644 --- a/packages/mui-base/src/Menu/Trigger/MenuTrigger.test.tsx +++ b/packages/mui-base/src/Menu/Trigger/MenuTrigger.test.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { expect } from 'chai'; import { FloatingRootContext, FloatingTree } from '@floating-ui/react'; import userEvent from '@testing-library/user-event'; -import { act } from '@mui/internal-test-utils'; +import { act, screen } from '@mui/internal-test-utils'; import { Menu } from '@base_ui/react/Menu'; import { describeConformance, createRenderer } from '#test-utils'; import { MenuRootContext } from '../Root/MenuRootContext'; @@ -164,4 +164,23 @@ describe('', () => { expect(button).to.have.attribute('aria-expanded', 'true'); }); }); + + describe('style hooks', () => { + it('should have the data-popup-open and data-pressed attributes when open', async () => { + await render( + + + , + ); + + const trigger = screen.getByRole('button'); + + await act(async () => { + trigger.click(); + }); + + expect(trigger).to.have.attribute('data-popup-open'); + expect(trigger).to.have.attribute('data-pressed'); + }); + }); }); diff --git a/packages/mui-base/src/Menu/Trigger/MenuTrigger.tsx b/packages/mui-base/src/Menu/Trigger/MenuTrigger.tsx index 4c800e0957..eb0ba70bef 100644 --- a/packages/mui-base/src/Menu/Trigger/MenuTrigger.tsx +++ b/packages/mui-base/src/Menu/Trigger/MenuTrigger.tsx @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; import { useFloatingTree } from '@floating-ui/react'; import { useMenuTrigger } from './useMenuTrigger'; import { useMenuRootContext } from '../Root/MenuRootContext'; -import { triggerOpenStateMapping } from '../../utils/popupOpenStateMapping'; +import { pressableTriggerOpenStateMapping } from '../../utils/popupOpenStateMapping'; import { useComponentRenderer } from '../../utils/useComponentRenderer'; import { BaseUIComponentProps } from '../../utils/types'; @@ -52,7 +52,7 @@ const MenuTrigger = React.forwardRef(function MenuTrigger( className, ownerState, propGetter: (externalProps) => getTriggerProps(getRootProps(externalProps)), - customStyleHookMapping: triggerOpenStateMapping, + customStyleHookMapping: pressableTriggerOpenStateMapping, extraProps: other, }); diff --git a/packages/mui-base/src/Popover/Root/PopoverRoot.tsx b/packages/mui-base/src/Popover/Root/PopoverRoot.tsx index 7a52ac417a..354645c25b 100644 --- a/packages/mui-base/src/Popover/Root/PopoverRoot.tsx +++ b/packages/mui-base/src/Popover/Root/PopoverRoot.tsx @@ -40,6 +40,7 @@ const PopoverRoot: React.FC = function PopoverRoot(props) { descriptionId, setDescriptionId, openMethod, + openReason, } = usePopoverRoot({ openOnHover, delay: delayWithDefault, @@ -73,6 +74,7 @@ const PopoverRoot: React.FC = function PopoverRoot(props) { getRootPopupProps, getRootTriggerProps, openMethod, + openReason, }), [ openOnHover, @@ -96,6 +98,7 @@ const PopoverRoot: React.FC = function PopoverRoot(props) { getRootPopupProps, getRootTriggerProps, openMethod, + openReason, ], ); diff --git a/packages/mui-base/src/Popover/Root/PopoverRootContext.ts b/packages/mui-base/src/Popover/Root/PopoverRootContext.ts index 9efaa21b9d..7850e5e252 100644 --- a/packages/mui-base/src/Popover/Root/PopoverRootContext.ts +++ b/packages/mui-base/src/Popover/Root/PopoverRootContext.ts @@ -27,6 +27,7 @@ export interface PopoverRootContext { getRootTriggerProps: (externalProps?: GenericHTMLProps) => GenericHTMLProps; getRootPopupProps: (externalProps?: GenericHTMLProps) => GenericHTMLProps; openMethod: InteractionType | null; + openReason: OpenChangeReason | null; } export const PopoverRootContext = React.createContext(undefined); diff --git a/packages/mui-base/src/Popover/Root/usePopoverRoot.ts b/packages/mui-base/src/Popover/Root/usePopoverRoot.ts index d91198215c..4368f9173d 100644 --- a/packages/mui-base/src/Popover/Root/usePopoverRoot.ts +++ b/packages/mui-base/src/Popover/Root/usePopoverRoot.ts @@ -42,6 +42,7 @@ export function usePopoverRoot(params: usePopoverRoot.Parameters): usePopoverRoo const [descriptionId, setDescriptionId] = React.useState(); const [triggerElement, setTriggerElement] = React.useState(null); const [positionerElement, setPositionerElement] = React.useState(null); + const [openReason, setOpenReason] = React.useState(null); const popupRef = React.useRef(null); @@ -69,6 +70,12 @@ export function usePopoverRoot(params: usePopoverRoot.Parameters): usePopoverRoo setMounted(false); } } + + if (nextOpen) { + setOpenReason(reason ?? null); + } else { + setOpenReason(null); + } }, ); @@ -138,6 +145,7 @@ export function usePopoverRoot(params: usePopoverRoot.Parameters): usePopoverRoo floatingRootContext: context, instantType, openMethod, + openReason, }), [ mounted, @@ -154,6 +162,7 @@ export function usePopoverRoot(params: usePopoverRoot.Parameters): usePopoverRoo instantType, openMethod, triggerProps, + openReason, ], ); } @@ -222,5 +231,6 @@ export namespace usePopoverRoot { setPositionerElement: React.Dispatch>; popupRef: React.RefObject; openMethod: InteractionType | null; + openReason: OpenChangeReason | null; } } diff --git a/packages/mui-base/src/Popover/Trigger/PopoverTrigger.test.tsx b/packages/mui-base/src/Popover/Trigger/PopoverTrigger.test.tsx index 9bb0c77738..53b6e382f0 100644 --- a/packages/mui-base/src/Popover/Trigger/PopoverTrigger.test.tsx +++ b/packages/mui-base/src/Popover/Trigger/PopoverTrigger.test.tsx @@ -1,6 +1,8 @@ import * as React from 'react'; import { Popover } from '@base_ui/react/Popover'; import { createRenderer, describeConformance } from '#test-utils'; +import { expect } from 'chai'; +import { act, screen } from '@mui/internal-test-utils'; describe('', () => { const { render } = createRenderer(); @@ -15,4 +17,56 @@ describe('', () => { ); }, })); + + describe('style hooks', () => { + it('should have the data-popup-open and data-pressed attributes when open by clicking', async () => { + await render( + + + , + ); + + const trigger = screen.getByRole('button'); + + await act(async () => { + trigger.click(); + }); + + expect(trigger).to.have.attribute('data-popup-open'); + expect(trigger).to.have.attribute('data-pressed'); + }); + + it('should have the data-popup-open but not the data-pressed attribute when open by hover', async () => { + const { user } = await render( + + + , + ); + + const trigger = screen.getByRole('button'); + + await user.hover(trigger); + + expect(trigger).to.have.attribute('data-popup-open'); + expect(trigger).not.to.have.attribute('data-pressed'); + }); + + it('should have the data-popup-open and data-pressed attributes when open by click when `openOnHover=true`', async () => { + const { user } = await render( + + + , + ); + + const trigger = screen.getByRole('button'); + + await user.hover(trigger); + await act(async () => { + trigger.click(); + }); + + expect(trigger).to.have.attribute('data-popup-open'); + expect(trigger).to.have.attribute('data-pressed'); + }); + }); }); diff --git a/packages/mui-base/src/Popover/Trigger/PopoverTrigger.tsx b/packages/mui-base/src/Popover/Trigger/PopoverTrigger.tsx index 42906c985f..1d9057c2d1 100644 --- a/packages/mui-base/src/Popover/Trigger/PopoverTrigger.tsx +++ b/packages/mui-base/src/Popover/Trigger/PopoverTrigger.tsx @@ -5,7 +5,11 @@ import { usePopoverRootContext } from '../Root/PopoverRootContext'; import { useComponentRenderer } from '../../utils/useComponentRenderer'; import { useForkRef } from '../../utils/useForkRef'; import type { BaseUIComponentProps } from '../../utils/types'; -import { triggerOpenStateMapping } from '../../utils/popupOpenStateMapping'; +import { + triggerOpenStateMapping, + pressableTriggerOpenStateMapping, +} from '../../utils/popupOpenStateMapping'; +import { CustomStyleHookMapping } from '../../utils/getStyleHookProps'; /** * Renders a trigger element that opens the popover. @@ -24,12 +28,25 @@ const PopoverTrigger = React.forwardRef(function PopoverTrigger( ) { const { render, className, ...otherProps } = props; - const { open, setTriggerElement, getRootTriggerProps } = usePopoverRootContext(); + const { open, setTriggerElement, getRootTriggerProps, openReason } = usePopoverRootContext(); const ownerState: PopoverTrigger.OwnerState = React.useMemo(() => ({ open }), [open]); const mergedRef = useForkRef(forwardedRef, setTriggerElement); + const customStyleHookMapping: CustomStyleHookMapping<{ open: boolean }> = React.useMemo( + () => ({ + open(value) { + if (value && openReason === 'click') { + return pressableTriggerOpenStateMapping.open(value); + } + + return triggerOpenStateMapping.open(value); + }, + }), + [openReason], + ); + const { renderElement } = useComponentRenderer({ propGetter: getRootTriggerProps, render: render ?? 'button', @@ -37,7 +54,7 @@ const PopoverTrigger = React.forwardRef(function PopoverTrigger( ownerState, ref: mergedRef, extraProps: otherProps, - customStyleHookMapping: triggerOpenStateMapping, + customStyleHookMapping, }); return renderElement(); diff --git a/packages/mui-base/src/Select/Trigger/SelectTrigger.test.tsx b/packages/mui-base/src/Select/Trigger/SelectTrigger.test.tsx new file mode 100644 index 0000000000..f4c0b1e8c9 --- /dev/null +++ b/packages/mui-base/src/Select/Trigger/SelectTrigger.test.tsx @@ -0,0 +1,39 @@ +import * as React from 'react'; +import { Select } from '@base_ui/react/Select'; +import { createRenderer, describeConformance } from '#test-utils'; +import { expect } from 'chai'; +import { act, screen } from '@mui/internal-test-utils'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + refInstanceof: window.HTMLDivElement, + render(node) { + return render( + + {node} + , + ); + }, + })); + + describe('style hooks', () => { + it('should have the data-popup-open and data-pressed attributes when open', async () => { + await render( + + + , + ); + + const trigger = screen.getByRole('combobox'); + + await act(async () => { + trigger.click(); + }); + + expect(trigger).to.have.attribute('data-popup-open'); + expect(trigger).to.have.attribute('data-pressed'); + }); + }); +}); diff --git a/packages/mui-base/src/Select/Trigger/SelectTrigger.tsx b/packages/mui-base/src/Select/Trigger/SelectTrigger.tsx index 909706b9ad..856ac0dbe5 100644 --- a/packages/mui-base/src/Select/Trigger/SelectTrigger.tsx +++ b/packages/mui-base/src/Select/Trigger/SelectTrigger.tsx @@ -6,7 +6,7 @@ import { useSelectRootContext } from '../Root/SelectRootContext'; import { useComponentRenderer } from '../../utils/useComponentRenderer'; import { BaseUIComponentProps } from '../../utils/types'; import { useFieldRootContext } from '../../Field/Root/FieldRootContext'; -import { triggerOpenStateMapping } from '../../utils/popupOpenStateMapping'; +import { pressableTriggerOpenStateMapping } from '../../utils/popupOpenStateMapping'; /** * @@ -55,7 +55,7 @@ const SelectTrigger = React.forwardRef(function SelectTrigger( className, ownerState, propGetter: (externalProps) => getTriggerProps(getRootTriggerProps(externalProps)), - customStyleHookMapping: triggerOpenStateMapping, + customStyleHookMapping: pressableTriggerOpenStateMapping, extraProps: otherProps, }); diff --git a/packages/mui-base/src/utils/popupOpenStateMapping.ts b/packages/mui-base/src/utils/popupOpenStateMapping.ts index a17d2248f4..07e4ef00fe 100644 --- a/packages/mui-base/src/utils/popupOpenStateMapping.ts +++ b/packages/mui-base/src/utils/popupOpenStateMapping.ts @@ -1,23 +1,41 @@ import type { CustomStyleHookMapping } from './getStyleHookProps'; -export const triggerOpenStateMapping: CustomStyleHookMapping<{ open: boolean }> = { +const TRIGGER_HOOK = { + 'data-popup-open': '', +}; + +const PRESSABLE_TRIGGER_HOOK = { + 'data-popup-open': '', + 'data-pressed': '', +}; + +const POPUP_HOOK = { + 'data-open': '', +}; + +export const triggerOpenStateMapping = { open(value) { if (value) { - return { - 'data-popup-open': '', - }; + return TRIGGER_HOOK; } return null; }, -}; +} satisfies CustomStyleHookMapping<{ open: boolean }>; -export const popupOpenStateMapping: CustomStyleHookMapping<{ open: boolean }> = { +export const pressableTriggerOpenStateMapping = { open(value) { if (value) { - return { - 'data-open': '', - }; + return PRESSABLE_TRIGGER_HOOK; } return null; }, -}; +} satisfies CustomStyleHookMapping<{ open: boolean }>; + +export const popupOpenStateMapping = { + open(value) { + if (value) { + return POPUP_HOOK; + } + return null; + }, +} satisfies CustomStyleHookMapping<{ open: boolean }>;