Skip to content

Commit

Permalink
[Menu, Popover, Select] Add data-pressed to MenuTrigger, PopoverTri…
Browse files Browse the repository at this point in the history
…gger and SelectTrigger (#826)
  • Loading branch information
michaldudak authored Nov 19, 2024
1 parent d7089ab commit c39d873
Show file tree
Hide file tree
Showing 11 changed files with 182 additions and 18 deletions.
3 changes: 3 additions & 0 deletions docs/data/api/select-trigger.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<ul><li><a href=\"/components/react-select/\">Select</a></li></ul>",
Expand Down
21 changes: 20 additions & 1 deletion packages/mui-base/src/Menu/Trigger/MenuTrigger.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -164,4 +164,23 @@ describe('<Menu.Trigger />', () => {
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(
<Menu.Root animated={false}>
<Menu.Trigger />
</Menu.Root>,
);

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');
});
});
});
4 changes: 2 additions & 2 deletions packages/mui-base/src/Menu/Trigger/MenuTrigger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -52,7 +52,7 @@ const MenuTrigger = React.forwardRef(function MenuTrigger(
className,
ownerState,
propGetter: (externalProps) => getTriggerProps(getRootProps(externalProps)),
customStyleHookMapping: triggerOpenStateMapping,
customStyleHookMapping: pressableTriggerOpenStateMapping,
extraProps: other,
});

Expand Down
3 changes: 3 additions & 0 deletions packages/mui-base/src/Popover/Root/PopoverRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const PopoverRoot: React.FC<PopoverRoot.Props> = function PopoverRoot(props) {
descriptionId,
setDescriptionId,
openMethod,
openReason,
} = usePopoverRoot({
openOnHover,
delay: delayWithDefault,
Expand Down Expand Up @@ -73,6 +74,7 @@ const PopoverRoot: React.FC<PopoverRoot.Props> = function PopoverRoot(props) {
getRootPopupProps,
getRootTriggerProps,
openMethod,
openReason,
}),
[
openOnHover,
Expand All @@ -96,6 +98,7 @@ const PopoverRoot: React.FC<PopoverRoot.Props> = function PopoverRoot(props) {
getRootPopupProps,
getRootTriggerProps,
openMethod,
openReason,
],
);

Expand Down
1 change: 1 addition & 0 deletions packages/mui-base/src/Popover/Root/PopoverRootContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PopoverRootContext | undefined>(undefined);
Expand Down
10 changes: 10 additions & 0 deletions packages/mui-base/src/Popover/Root/usePopoverRoot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export function usePopoverRoot(params: usePopoverRoot.Parameters): usePopoverRoo
const [descriptionId, setDescriptionId] = React.useState<string>();
const [triggerElement, setTriggerElement] = React.useState<Element | null>(null);
const [positionerElement, setPositionerElement] = React.useState<HTMLElement | null>(null);
const [openReason, setOpenReason] = React.useState<OpenChangeReason | null>(null);

const popupRef = React.useRef<HTMLElement>(null);

Expand Down Expand Up @@ -69,6 +70,12 @@ export function usePopoverRoot(params: usePopoverRoot.Parameters): usePopoverRoo
setMounted(false);
}
}

if (nextOpen) {
setOpenReason(reason ?? null);
} else {
setOpenReason(null);
}
},
);

Expand Down Expand Up @@ -138,6 +145,7 @@ export function usePopoverRoot(params: usePopoverRoot.Parameters): usePopoverRoo
floatingRootContext: context,
instantType,
openMethod,
openReason,
}),
[
mounted,
Expand All @@ -154,6 +162,7 @@ export function usePopoverRoot(params: usePopoverRoot.Parameters): usePopoverRoo
instantType,
openMethod,
triggerProps,
openReason,
],
);
}
Expand Down Expand Up @@ -222,5 +231,6 @@ export namespace usePopoverRoot {
setPositionerElement: React.Dispatch<React.SetStateAction<HTMLElement | null>>;
popupRef: React.RefObject<HTMLElement | null>;
openMethod: InteractionType | null;
openReason: OpenChangeReason | null;
}
}
54 changes: 54 additions & 0 deletions packages/mui-base/src/Popover/Trigger/PopoverTrigger.test.tsx
Original file line number Diff line number Diff line change
@@ -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('<Popover.Trigger />', () => {
const { render } = createRenderer();
Expand All @@ -15,4 +17,56 @@ describe('<Popover.Trigger />', () => {
);
},
}));

describe('style hooks', () => {
it('should have the data-popup-open and data-pressed attributes when open by clicking', async () => {
await render(
<Popover.Root animated={false}>
<Popover.Trigger />
</Popover.Root>,
);

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(
<Popover.Root openOnHover delay={0} animated={false}>
<Popover.Trigger />
</Popover.Root>,
);

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(
<Popover.Root openOnHover delay={0} animated={false}>
<Popover.Trigger />
</Popover.Root>,
);

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');
});
});
});
23 changes: 20 additions & 3 deletions packages/mui-base/src/Popover/Trigger/PopoverTrigger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -24,20 +28,33 @@ 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',
className,
ownerState,
ref: mergedRef,
extraProps: otherProps,
customStyleHookMapping: triggerOpenStateMapping,
customStyleHookMapping,
});

return renderElement();
Expand Down
39 changes: 39 additions & 0 deletions packages/mui-base/src/Select/Trigger/SelectTrigger.test.tsx
Original file line number Diff line number Diff line change
@@ -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('<Select.Trigger />', () => {
const { render } = createRenderer();

describeConformance(<Select.Trigger />, () => ({
refInstanceof: window.HTMLDivElement,
render(node) {
return render(
<Select.Root open animated={false}>
{node}
</Select.Root>,
);
},
}));

describe('style hooks', () => {
it('should have the data-popup-open and data-pressed attributes when open', async () => {
await render(
<Select.Root animated={false}>
<Select.Trigger />
</Select.Root>,
);

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');
});
});
});
4 changes: 2 additions & 2 deletions packages/mui-base/src/Select/Trigger/SelectTrigger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
*
Expand Down Expand Up @@ -55,7 +55,7 @@ const SelectTrigger = React.forwardRef(function SelectTrigger(
className,
ownerState,
propGetter: (externalProps) => getTriggerProps(getRootTriggerProps(externalProps)),
customStyleHookMapping: triggerOpenStateMapping,
customStyleHookMapping: pressableTriggerOpenStateMapping,
extraProps: otherProps,
});

Expand Down
38 changes: 28 additions & 10 deletions packages/mui-base/src/utils/popupOpenStateMapping.ts
Original file line number Diff line number Diff line change
@@ -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 }>;

0 comments on commit c39d873

Please sign in to comment.