Skip to content

Commit 10024b3

Browse files
author
Kubit
committed
Add drag functionality
1 parent 51551f5 commit 10024b3

File tree

6 files changed

+138
-12
lines changed

6 files changed

+138
-12
lines changed

src/components/oliveMenu/__test__/oliveMenu.test.tsx

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@ import { axe } from 'jest-axe';
66
import * as mediaHooks from '@/hooks/useMediaDevice/useMediaDevice';
77
import { renderProvider } from '@/tests/renderProvider/renderProvider.utility';
88
import { windowMatchMedia } from '@/tests/windowMatchMedia';
9-
import { DeviceBreakpointsType } from '@/types';
9+
import { DeviceBreakpointsType, ROLES } from '@/types';
1010

1111
import { OliveMenu } from '../oliveMenu';
1212
import { IOliveMenu, OliveMenuListOptions } from '../types';
1313

1414
const mockSections: OliveMenuListOptions[] = [
1515
{
1616
title: { content: 'list 1' },
17+
id: 'section1',
1718
options: [
1819
{
1920
label: 'option 1',
@@ -33,6 +34,7 @@ const mockSections: OliveMenuListOptions[] = [
3334
},
3435
{
3536
title: { content: 'list 2' },
37+
id: 'section2',
3638
options: [
3739
{
3840
label: 'option 1',
@@ -54,6 +56,9 @@ const mockSections: OliveMenuListOptions[] = [
5456

5557
const mockProps: IOliveMenu = {
5658
actionBottomSheetStructure: {
59+
title: {
60+
content: 'title',
61+
},
5762
closeIcon: {
5863
icon: 'UNICORN',
5964
altText: 'Close Menu',
@@ -66,6 +71,7 @@ const mockProps: IOliveMenu = {
6671
},
6772
variant: 'PRIMARY',
6873
['aria-label']: 'Open menu',
74+
size: 'MEDIUM',
6975
},
7076
variant: 'DEFAULT',
7177
screenReaderText: 'Menu openned',
@@ -146,9 +152,8 @@ describe('OliveMenu component', () => {
146152
const openButton = screen.getAllByText('Options');
147153
fireEvent.click(openButton[0]);
148154

149-
// Element in the popover
150-
const popover = container.querySelector('[data-popover]');
151-
expect(popover?.tagName).toBe('DIALOG');
155+
const popover = screen.getByRole(ROLES.DIALOG);
156+
expect(popover).toBeInTheDocument();
152157

153158
const results = await axe(container);
154159
expect(container).toHTMLValidate({
@@ -278,4 +283,33 @@ describe('OliveMenu component', () => {
278283

279284
expect(popoverElement).not.toBeInTheDocument();
280285
});
286+
287+
// desktop will close because of the blur
288+
it('Should close the menu when clicking outside if mobile/tablet', async () => {
289+
const handleOpenClose = jest.fn();
290+
window.matchMedia = windowMatchMedia('onlyMobile');
291+
jest.useFakeTimers();
292+
jest.spyOn(mediaHooks, 'useMediaDevice').mockImplementation(() => DeviceBreakpointsType.MOBILE);
293+
renderProvider(
294+
<div>
295+
<button>external</button>
296+
<OliveMenu {...mockProps} onOpenClose={handleOpenClose} />
297+
</div>
298+
);
299+
300+
// Open the popover
301+
const openButton = screen.getAllByText('Options');
302+
act(() => {
303+
fireEvent.click(openButton[0]);
304+
});
305+
306+
// Close popover
307+
const externalElement = screen.getByText('external');
308+
await act(() => {
309+
fireEvent.mouseUp(externalElement);
310+
jest.runAllTimers();
311+
});
312+
313+
expect(handleOpenClose).toHaveBeenCalledWith(false);
314+
});
281315
});

src/components/oliveMenu/__test__/oliveMenu.utils.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,59 @@ const menuSection: OliveMenuListOptions[] = [
4545
},
4646
];
4747

48+
const menuSectionWithoutId: OliveMenuListOptions[] = [
49+
{
50+
title: { content: 'label1' },
51+
options: [
52+
{
53+
label: 'option 1',
54+
value: 1,
55+
},
56+
{
57+
label: 'option 2',
58+
59+
value: 2,
60+
},
61+
{
62+
label: 'option 3',
63+
url: 'https://www.google.com/',
64+
['aria-label']: 'link to google 1',
65+
value: 37,
66+
},
67+
],
68+
},
69+
{
70+
title: { content: 'label2' },
71+
options: [
72+
{
73+
label: 'option 1',
74+
value: 3,
75+
},
76+
{
77+
label: 'option 2',
78+
value: 56,
79+
},
80+
{
81+
label: 'option 3',
82+
url: 'https://www.google.com/',
83+
['aria-label']: 'link to google 1',
84+
value: 6,
85+
},
86+
],
87+
},
88+
];
89+
4890
describe('Olive Menu utils', () => {
4991
test('Should get ariaControls', async () => {
5092
expect(getAriaControls(menuSection, 'ariaControls')).toStrictEqual([
5193
'ariaControls0number1',
5294
'ariaControls1number2',
5395
]);
5496
});
97+
test('Should get ariaControls without id', async () => {
98+
expect(getAriaControls(menuSectionWithoutId, 'ariaControls')).toStrictEqual([
99+
'ariaControls0',
100+
'ariaControls1',
101+
]);
102+
});
55103
});

src/components/oliveMenu/oliveMenu.tsx

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import * as React from 'react';
22

3-
import { useEscPressedV2, useMediaDevice } from '@/hooks';
3+
import { useEscPressedV2, useMediaDevice, useSwipeDown } from '@/hooks';
44
import { useStyles } from '@/hooks/useStyles/useStyles';
55
import { ErrorBoundary, FallbackComponent } from '@/provider/errorBoundary';
6+
import { DeviceBreakpointsType } from '@/types';
67

78
import { OliveMenuStandAlone } from './oliveMenuStandAlone';
89
import { IOliveMenu, IOliveMenuStandAlone, OliveMenuGlobalStylesType } from './types';
@@ -19,6 +20,7 @@ const OliveMenuComponent = React.forwardRef(
1920
const device = useMediaDevice();
2021

2122
const innerRef = React.useRef<HTMLDivElement>(null);
23+
const actionBottomSheetRef = React.useRef<HTMLDivElement | null>(null);
2224
React.useImperativeHandle(ref, () => innerRef.current as HTMLDivElement, []);
2325

2426
const handlePressScape = React.useCallback(() => {
@@ -28,8 +30,13 @@ const OliveMenuComponent = React.forwardRef(
2830
}
2931
setOpen(false);
3032
onOpenClose?.(false);
33+
// Focus the trigger button since its not handled automatically by the popover
34+
// and the focus in inside the component that is going to be closed
35+
const trigger = innerRef.current?.querySelector('button');
36+
trigger?.focus();
3137
}, [open]);
3238

39+
// It is handled internally by the component to allow close when the focus is on the button
3340
useEscPressedV2({ ref: innerRef, onEscPress: handlePressScape });
3441

3542
const handleTriggerClick = e => {
@@ -44,25 +51,57 @@ const OliveMenuComponent = React.forwardRef(
4451
actionBottomSheetStructure?.closeIcon?.onClick?.(e);
4552
};
4653

54+
const handleCloseInternally = () => {
55+
setOpen(false);
56+
onOpenClose?.(false);
57+
props.popover?.onCloseInternally?.();
58+
};
59+
4760
const handleBlur: React.FocusEventHandler<HTMLDivElement> = e => {
48-
// Do nothing if the new focus is inside the component or already closed
49-
if (e.currentTarget.contains(e.relatedTarget) || !open) {
61+
// Do nothing if:
62+
// * Is device with popover (mobile or tablet)
63+
// * the new focus is inside the component
64+
// * it is already closed
65+
if (
66+
[DeviceBreakpointsType.MOBILE, DeviceBreakpointsType.TABLET].includes(device) ||
67+
e.currentTarget.contains(e.relatedTarget) ||
68+
!open
69+
) {
5070
return;
5171
}
5272
setOpen(false);
5373
onOpenClose?.(false, e);
5474
};
5575

76+
const { setPopoverRef, setDragIconRef } = useSwipeDown(
77+
props.popover?.animationOptions,
78+
handleCloseInternally
79+
);
80+
81+
const setInnerRef = React.useCallback(node => {
82+
actionBottomSheetRef.current = node;
83+
const dragIcon = actionBottomSheetRef.current?.querySelector('[data-drag-icon]');
84+
if (dragIcon instanceof HTMLElement) {
85+
setDragIconRef(dragIcon);
86+
}
87+
}, []);
88+
5689
return (
5790
<OliveMenuStandAlone
5891
{...props}
5992
ref={innerRef}
6093
actionBottomSheetStructure={{
6194
...actionBottomSheetStructure,
95+
forwardedRef: setInnerRef,
6296
closeIcon: { ...actionBottomSheetStructure?.closeIcon, onClick: handleCloseIconClick },
6397
}}
6498
device={device}
6599
open={open}
100+
popover={{
101+
...props.popover,
102+
forwardedRef: setPopoverRef,
103+
onCloseInternally: handleCloseInternally,
104+
}}
66105
styles={styles}
67106
trigger={trigger && { ...trigger, onClick: handleTriggerClick }}
68107
onBlur={handleBlur}

src/components/oliveMenu/oliveMenuStandAlone.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { PopoverControlled as Popover, PopoverComponentType } from '@/components
77
import { ScreenReaderOnly } from '@/components/screenReaderOnly';
88
import { TextComponentType } from '@/components/text/types';
99
import { useId } from '@/hooks';
10-
import { AriaLiveOptionType, DeviceBreakpointsType } from '@/types';
10+
import { AriaLiveOptionType, DeviceBreakpointsType, ROLES } from '@/types';
1111

1212
import { ButtonContainer, ListboxStyled, OliveMenuStyled } from './oliveMenu.styled';
1313
import { IOliveMenuStandAlone } from './types';
@@ -62,9 +62,8 @@ const OliveMenuStandAloneComponent = (
6262
<Popover
6363
aria-labelledby={popoverAsModal ? titleId : undefined}
6464
aria-modal={popoverAsModal ? props.open : undefined}
65-
// It is handled internally by the onBlur function of the container
66-
clickOverlayClose={false}
67-
component={popoverAsModal ? PopoverComponentType.DIALOG : PopoverComponentType.DIV}
65+
clickOverlayClose={true}
66+
component={PopoverComponentType.DIV}
6867
dataTestId={`${props.dataTestId}Popover`}
6968
extraAlignGap={props.styles.buttonContainer?.[props.device]?.margin_bottom}
7069
focusFirstDescendantAutomatically={popoverAsModal}
@@ -73,11 +72,14 @@ const OliveMenuStandAloneComponent = (
7372
open={props.open}
7473
// It is handled internally by the controlled component to allow close when the focus is on the button
7574
pressEscapeClose={false}
75+
preventCloseOnClickElements={[buttonRef.current]}
76+
role={popoverAsModal ? ROLES.DIALOG : undefined}
7677
trapFocusInsideModal={popoverAsModal}
7778
variant={props.styles.popoverVariant}
7879
{...props.popover}
7980
>
8081
<ActionBottomSheetControlledStructure
82+
ref={actionBottomSheet.forwardedRef}
8183
dataTestId={`${props.dataTestId}ActionButtonSheet`}
8284
title={{
8385
component: TextComponentType.H5,

src/components/oliveMenu/stories/oliveMenu.stories.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Meta, StoryObj } from '@storybook/react';
22

3+
import { ICONS } from '@/assets';
34
import { STYLES_NAME } from '@/constants';
45
import { themesObject, variantsObject } from '@/designSystem/themesObject';
56

@@ -80,6 +81,7 @@ const commonArgs: IOliveMenu = {
8081
screenReaderText: 'textReaderOpen',
8182
actionBottomSheetStructure: {
8283
hasHeader: false,
84+
dragIcon: { icon: ICONS.ICON_DRAG, ['aria-label']: 'drag icon' },
8385
},
8486
};
8587

src/components/oliveMenu/types/oliveMenu.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@ export type OliveMenuTriggerType = Omit<IButton, 'children' | 'size'> & {
1515

1616
export type OliveMenuActionBottomSheetStructure = Omit<
1717
IActionBottomSheetControlledStructure,
18-
'children' | 'variant'
18+
'children' | 'variant' | 'dragIconRef'
1919
> & {
2020
variant?: string;
21+
forwardedRef?: (node: HTMLDivElement) => void;
2122
};
2223

2324
export type OliveMenuListOptions = Omit<

0 commit comments

Comments
 (0)