Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Menu, Popover, Select] Add data-pressed to MenuTrigger, PopoverTrigger and SelectTrigger #826

Merged
merged 8 commits into from
Nov 19, 2024
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
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 }>;