Skip to content

Commit

Permalink
Merge branch 'master' into feat/onOpenChangeComplete
Browse files Browse the repository at this point in the history
Signed-off-by: atomiks <[email protected]>
  • Loading branch information
atomiks authored Jan 29, 2025
2 parents 920eb15 + 851d1c1 commit 4fa7671
Show file tree
Hide file tree
Showing 15 changed files with 279 additions and 78 deletions.
2 changes: 1 addition & 1 deletion docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
"react-dom": "^19.0.0",
"react-error-boundary": "^4.1.2",
"react-is": "^19.0.0",
"react-router-dom": "^6.28.1",
"react-router-dom": "^7.1.3",
"react-runner": "^1.0.5",
"rehype-pretty-code": "^0.14.0",
"remark": "^15.0.1",
Expand Down
2 changes: 1 addition & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@
"publint": "^0.3.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^6.28.2",
"react-router-dom": "^7.1.3",
"sinon": "^19.0.2",
"typescript": "^5.7.3"
},
Expand Down
25 changes: 15 additions & 10 deletions packages/react/src/menu/positioner/useMenuPositioner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export function useMenuPositioner(
): useMenuPositioner.ReturnValue {
const { nodeId, parentNodeId } = params;

const { open, setOpen, mounted } = useMenuRootContext();
const { open, setOpen, mounted, setHoverEnabled } = useMenuRootContext();

const positioning = useAnchorPositioning(params);

Expand All @@ -38,23 +38,28 @@ export function useMenuPositioner(
);

React.useEffect(() => {
function onMenuOpened(event: { nodeId: string; parentNodeId: string }) {
if (event.nodeId !== nodeId && event.parentNodeId === parentNodeId) {
setOpen(false, undefined);
function onMenuOpenChange(event: { open: boolean; nodeId: string; parentNodeId: string }) {
if (event.open) {
if (event.parentNodeId === nodeId) {
setHoverEnabled(false);
}
if (event.nodeId !== nodeId && event.parentNodeId === parentNodeId) {
setOpen(false, undefined);
}
} else if (event.parentNodeId === nodeId) {
setHoverEnabled(true);
}
}

menuEvents.on('opened', onMenuOpened);
menuEvents.on('openchange', onMenuOpenChange);

return () => {
menuEvents.off('opened', onMenuOpened);
menuEvents.off('openchange', onMenuOpenChange);
};
}, [menuEvents, nodeId, parentNodeId, setOpen]);
}, [menuEvents, nodeId, parentNodeId, setOpen, setHoverEnabled]);

React.useEffect(() => {
if (open) {
menuEvents.emit('opened', { nodeId, parentNodeId });
}
menuEvents.emit('openchange', { open, nodeId, parentNodeId });
}, [menuEvents, open, nodeId, parentNodeId]);

return React.useMemo(
Expand Down
126 changes: 126 additions & 0 deletions packages/react/src/menu/root/MenuRoot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1049,5 +1049,131 @@ describe('<Menu.Root />', () => {

expect(screen.queryByTestId('popup')).not.to.equal(null);
});
});

describe('prop: openOnHover', () => {
it('should open the menu when the trigger is hovered', async () => {
const { getByRole, queryByRole } = await render(
<Menu.Root openOnHover delay={0}>
<Menu.Trigger>Open</Menu.Trigger>
<Menu.Portal>
<Menu.Positioner>
<Menu.Popup>
<Menu.Item>1</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>,
);

const trigger = getByRole('button', { name: 'Open' });

await act(async () => {
trigger.focus();
});

await userEvent.hover(trigger);

await waitFor(() => {
expect(queryByRole('menu')).not.to.equal(null);
});
});

it('should close the menu when the trigger is no longer hovered', async () => {
const { getByRole, queryByRole } = await render(
<Menu.Root openOnHover delay={0}>
<Menu.Trigger>Open</Menu.Trigger>
<Menu.Portal>
<Menu.Positioner>
<Menu.Popup>
<Menu.Item>1</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>,
);

const trigger = getByRole('button', { name: 'Open' });

await act(async () => {
trigger.focus();
});

await userEvent.hover(trigger);

await waitFor(() => {
expect(queryByRole('menu')).not.to.equal(null);
});

await userEvent.unhover(trigger);

await waitFor(() => {
expect(queryByRole('menu')).to.equal(null);
});
});

it('should not close when submenu is hovered after root menu is hovered', async () => {
const { getByRole, getByTestId } = await render(
<Menu.Root openOnHover delay={0}>
<Menu.Trigger>Open</Menu.Trigger>
<Menu.Portal>
<Menu.Positioner data-testid="menu">
<Menu.Popup>
<Menu.Item>1</Menu.Item>
<Menu.Root delay={0}>
<Menu.SubmenuTrigger>2</Menu.SubmenuTrigger>
<Menu.Portal>
<Menu.Positioner data-testid="submenu">
<Menu.Popup>
<Menu.Item>2.1</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>,
);

const trigger = getByRole('button', { name: 'Open' });

await act(async () => {
trigger.focus();
});

await userEvent.hover(trigger);

await waitFor(() => {
expect(getByTestId('menu')).not.to.equal(null);
});

const menu = getByTestId('menu');

await userEvent.hover(menu);

const submenuTrigger = getByRole('menuitem', { name: '2' });

await userEvent.hover(submenuTrigger);

await waitFor(() => {
expect(getByTestId('menu')).not.to.equal(null);
});
await waitFor(() => {
expect(getByTestId('submenu')).not.to.equal(null);
});

const submenu = getByTestId('submenu');

await userEvent.unhover(menu);
await userEvent.hover(submenu);

await waitFor(() => {
expect(getByTestId('menu')).not.to.equal(null);
});
await waitFor(() => {
expect(getByTestId('submenu')).not.to.equal(null);
});
});
});
});
1 change: 1 addition & 0 deletions packages/react/src/menu/root/MenuRootContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface MenuRootContext extends useMenuRoot.ReturnValue {
modal: boolean;
openReason: OpenChangeReason | null;
onOpenChangeComplete: ((open: boolean) => void) | undefined;
setHoverEnabled: React.Dispatch<React.SetStateAction<boolean>>;
}

export const MenuRootContext = React.createContext<MenuRootContext | undefined>(undefined);
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/menu/root/useMenuRoot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ export function useMenuRoot(parameters: useMenuRoot.Parameters): useMenuRoot.Ret
openReason,
instantType,
onOpenChangeComplete,
setHoverEnabled,
}),
[
activeIndex,
Expand Down Expand Up @@ -377,5 +378,6 @@ export namespace useMenuRoot {
openReason: OpenChangeReason | null;
instantType: 'dismiss' | 'click' | undefined;
onOpenChangeComplete: ((open: boolean) => void) | undefined;
setHoverEnabled: React.Dispatch<React.SetStateAction<boolean>>;
}
}
52 changes: 52 additions & 0 deletions packages/react/src/tabs/root/TabsRoot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,58 @@ describe('<Tabs.Root />', () => {
});
});

describe('pointer navigation', () => {
it('activates the clicked tab', async () => {
const { user } = await render(
<Tabs.Root defaultValue={0}>
<Tabs.List activateOnFocus={false}>
<Tabs.Tab value={0}>Tab 1</Tabs.Tab>
<Tabs.Tab value={1}>Tab 2</Tabs.Tab>
<Tabs.Tab value={2}>Tab 3</Tabs.Tab>
</Tabs.List>
<Tabs.Panel>Panel 1</Tabs.Panel>
<Tabs.Panel>Panel 2</Tabs.Panel>
<Tabs.Panel>Panel 3</Tabs.Panel>
</Tabs.Root>,
);

const tab2 = screen.getByRole('tab', { name: 'Tab 2' });
await user.click(tab2);

const panels = screen.getAllByRole('tabpanel', { hidden: true });

expect(panels[0]).to.have.attribute('hidden');
expect(panels[1]).not.to.have.attribute('hidden');
expect(panels[2]).to.have.attribute('hidden');
});

it('does not activate the clicked disabled tab', async () => {
const { user } = await render(
<Tabs.Root defaultValue={0}>
<Tabs.List activateOnFocus={false}>
<Tabs.Tab value={0}>Tab 1</Tabs.Tab>
<Tabs.Tab disabled value={1}>
Tab 2
</Tabs.Tab>
<Tabs.Tab value={2}>Tab 3</Tabs.Tab>
</Tabs.List>
<Tabs.Panel>Panel 1</Tabs.Panel>
<Tabs.Panel>Panel 2</Tabs.Panel>
<Tabs.Panel>Panel 3</Tabs.Panel>
</Tabs.Root>,
);

const tab2 = screen.getByRole('tab', { name: 'Tab 2' });
await user.click(tab2);

const panels = screen.getAllByRole('tabpanel', { hidden: true });

expect(panels[0]).not.to.have.attribute('hidden');
expect(panels[1]).to.have.attribute('hidden');
expect(panels[2]).to.have.attribute('hidden');
});
});

describe('keyboard navigation when focus is on a tab', () => {
[
['horizontal', 'ltr', 'ArrowLeft', 'ArrowRight'],
Expand Down
7 changes: 4 additions & 3 deletions packages/react/src/tabs/tab/useTabsTab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,14 @@ function useTabsTab(parameters: useTabsTab.Parameters): useTabsTab.ReturnValue {
id,
ref: handleRef,
onClick(event) {
if (selected) {
if (selected || disabled) {
return;
}

onTabActivation(tabValue, event.nativeEvent);
},
onFocus(event) {
if (!activateOnFocus || selected) {
if (!activateOnFocus || selected || disabled) {
return;
}

Expand All @@ -106,7 +106,7 @@ function useTabsTab(parameters: useTabsTab.Parameters): useTabsTab.ReturnValue {
}
},
onPointerDown(event) {
if (selected) {
if (selected || disabled) {
return;
}

Expand Down Expand Up @@ -138,6 +138,7 @@ function useTabsTab(parameters: useTabsTab.Parameters): useTabsTab.ReturnValue {
selected,
tabPanelId,
tabValue,
disabled,
],
);

Expand Down
16 changes: 11 additions & 5 deletions packages/react/src/utils/useScrollLock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import { isFirefox, isIOS, isWebKit } from './detectBrowser';
import { ownerDocument, ownerWindow } from './owner';
import { useEnhancedEffect } from './useEnhancedEffect';

let originalHtmlStyles = {};
let originalBodyStyles = {};
let originalHtmlStyles: Partial<CSSStyleDeclaration> = {};
let originalBodyStyles: Partial<CSSStyleDeclaration> = {};
let originalHtmlScrollBehavior = '';
let preventScrollCount = 0;
let restore: () => void = () => {};

Expand Down Expand Up @@ -52,6 +53,7 @@ function preventScrollStandard(referenceElement?: Element | null) {
overflowY: html.style.overflowY,
overflowX: html.style.overflowX,
};
originalHtmlScrollBehavior = html.style.scrollBehavior;

originalBodyStyles = {
position: body.style.position,
Expand All @@ -60,6 +62,7 @@ function preventScrollStandard(referenceElement?: Element | null) {
boxSizing: body.style.boxSizing,
overflowY: body.style.overflowY,
overflowX: body.style.overflowX,
scrollBehavior: body.style.scrollBehavior,
};

// Handle `scrollbar-gutter` in Chrome when there is no scrollable content.
Expand Down Expand Up @@ -95,11 +98,13 @@ function preventScrollStandard(referenceElement?: Element | null) {
width: marginX || scrollbarWidth ? `calc(100vw - ${marginX + scrollbarWidth}px)` : '100vw',
boxSizing: 'border-box',
overflow: 'hidden',
scrollBehavior: 'unset',
});

body.scrollTop = scrollTop;
body.scrollLeft = scrollLeft;
html.setAttribute('data-base-ui-scroll-locked', '');
html.style.scrollBehavior = 'unset';
}

function cleanup() {
Expand All @@ -108,6 +113,7 @@ function preventScrollStandard(referenceElement?: Element | null) {
html.scrollTop = scrollTop;
html.scrollLeft = scrollLeft;
html.removeAttribute('data-base-ui-scroll-locked');
html.style.scrollBehavior = originalHtmlScrollBehavior;
}

function handleResize() {
Expand All @@ -131,15 +137,15 @@ function preventScrollStandard(referenceElement?: Element | null) {
*
* @param enabled - Whether to enable the scroll lock.
*/
export function useScrollLock(enabled: boolean = true, referenceElement?: Element | null) {
export function useScrollLock(enabled = true, referenceElement?: Element | null) {
const isReactAriaHook = React.useMemo(
() =>
enabled &&
(isIOS() ||
!supportsDvh() ||
// macOS Firefox "pops" scroll containers' scrollbars with our standard scroll lock
(isFirefox() && !hasInsetScrollbars())),
[enabled],
(isFirefox() && !hasInsetScrollbars(referenceElement))),
[enabled, referenceElement],
);

usePreventScroll({
Expand Down
Loading

0 comments on commit 4fa7671

Please sign in to comment.