Skip to content

Commit

Permalink
feat: draggable menu button (#250)
Browse files Browse the repository at this point in the history
- adds movable menu button

- splits layouts and initial bounds

- todo: reset menu button position if not visible on orientation change
  • Loading branch information
thenick775 authored Jan 26, 2025
1 parent 80a662e commit a2a4944
Show file tree
Hide file tree
Showing 13 changed files with 204 additions and 104 deletions.
35 changes: 19 additions & 16 deletions gbajs3/src/components/controls/control-panel.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ describe('<ControlPanel />', () => {

vi.spyOn(contextHooks, 'useLayoutContext').mockImplementation(() => ({
...original(),
layouts: {
...original().layouts,
screen: { initialBounds: { left: 0, bottom: 0 } as DOMRect }
initialBounds: {
...original().initialBounds,
screen: { left: 0, bottom: 0 } as DOMRect
}
}));
});
Expand Down Expand Up @@ -95,21 +95,28 @@ describe('<ControlPanel />', () => {
});

it('sets initial bounds when rendered', async () => {
const setLayoutSpy = vi.fn();
const setInitialBoundSpy = vi.fn();

const { useLayoutContext: originalLayout } = await vi.importActual<
typeof contextHooks
>('../../hooks/context.tsx');

vi.spyOn(contextHooks, 'useLayoutContext').mockImplementation(() => ({
...originalLayout(),
setLayout: setLayoutSpy
setInitialBound: setInitialBoundSpy
}));

renderWithContext(<ControlPanel />);

expect(setLayoutSpy).toHaveBeenCalledWith('controlPanel', {
initialBounds: expect.anything()
expect(setInitialBoundSpy).toHaveBeenCalledWith('controlPanel', {
bottom: 0,
height: 0,
left: 0,
right: 0,
top: 0,
width: 0,
x: 0,
y: 0
});
});

Expand All @@ -126,14 +133,11 @@ describe('<ControlPanel />', () => {
vi.spyOn(contextHooks, 'useLayoutContext').mockImplementation(() => ({
...originalLayout(),
setLayout: setLayoutSpy,
hasSetLayout: true,
layouts: { screen: { initialBounds: new DOMRect() } }
initialBounds: { screen: new DOMRect() }
}));

renderWithContext(<ControlPanel />);

setLayoutSpy.mockClear(); // clear calls from initial render

// simulate mouse events on wrapper
fireEvent.mouseDown(
screen.getByTestId('control-panel-wrapper'),
Expand Down Expand Up @@ -164,11 +168,12 @@ describe('<ControlPanel />', () => {

// needs to be a consistent object
const testLayout = {
clearLayouts: vi.fn(),
clearLayoutsAndBounds: vi.fn(),
setLayout: setLayoutSpy,
setLayouts: vi.fn(),
hasSetLayout: true,
layouts: { screen: { initialBounds: new DOMRect() } }
setInitialBound: vi.fn(),
layouts: {},
initialBounds: { screen: new DOMRect() }
};

vi.spyOn(contextHooks, 'useLayoutContext').mockImplementation(
Expand All @@ -177,8 +182,6 @@ describe('<ControlPanel />', () => {

renderWithContext(<ControlPanel />);

setLayoutSpy.mockClear(); // clear calls from initial render

fireEvent.resize(screen.getByTestId('control-panel-wrapper'));

// simulate mouse events on a resize handle
Expand Down
16 changes: 9 additions & 7 deletions gbajs3/src/components/controls/control-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ export const ControlPanel = () => {
const { isRunning } = useRunningContext();
const { areItemsDraggable, setAreItemsDraggable } = useDragContext();
const { areItemsResizable, setAreItemsResizable } = useResizeContext();
const { layouts, setLayout, hasSetLayout } = useLayoutContext();
const { layouts, setLayout, initialBounds, setInitialBound } =
useLayoutContext();
const theme = useTheme();
const isLargerThanPhone = useMediaQuery(theme.isLargerThanPhone);
const isMobileLandscape = useMediaQuery(theme.isMobileLandscape);
Expand All @@ -104,15 +105,16 @@ export const ControlPanel = () => {

const refSetLayout = useCallback(
(node: Rnd | null) => {
if (!hasSetLayout && node)
setLayout('controlPanel', {
initialBounds: node.resizableElement.current?.getBoundingClientRect()
});
if (!initialBounds?.controlPanel && node)
setInitialBound(
'controlPanel',
node?.resizableElement.current?.getBoundingClientRect()
);
},
[setLayout, hasSetLayout]
[initialBounds?.controlPanel, setInitialBound]
);

const canvasBounds = layouts?.screen?.initialBounds;
const canvasBounds = initialBounds?.screen;

if (!canvasBounds) return null;

Expand Down
6 changes: 3 additions & 3 deletions gbajs3/src/components/controls/virtual-controls.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ describe('<VirtualControls />', () => {

vi.spyOn(contextHooks, 'useLayoutContext').mockImplementation(() => ({
...original(),
layouts: {
...original().layouts,
controlPanel: { initialBounds: { left: 0, bottom: 0 } as DOMRect }
initialBounds: {
...original().initialBounds,
controlPanel: { left: 0, bottom: 0 } as DOMRect
}
}));
});
Expand Down
6 changes: 3 additions & 3 deletions gbajs3/src/components/controls/virtual-controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export const VirtualControls = () => {
const { isRunning } = useRunningContext();
const { isAuthenticated } = useAuthContext();
const { setModalContent, setIsModalOpen } = useModalContext();
const { layouts } = useLayoutContext();
const { initialBounds } = useLayoutContext();
const virtualControlToastId = useId();
const quickReload = useQuickReload();
const { syncActionIfEnabled } = useAddCallbacks();
Expand All @@ -73,8 +73,8 @@ export const VirtualControls = () => {
AreVirtualControlsEnabledProps | undefined
>(virtualControlsLocalStorageKey);

const controlPanelBounds = layouts?.controlPanel?.initialBounds;
const canvasBounds = layouts?.screen?.initialBounds;
const controlPanelBounds = initialBounds?.controlPanel;
const canvasBounds = initialBounds?.screen;

if (!controlPanelBounds) return null;

Expand Down
6 changes: 3 additions & 3 deletions gbajs3/src/components/modals/controls.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,14 @@ describe('<ControlsModal />', () => {
});

it('resets movable control layouts', async () => {
const clearLayoutsSpy = vi.fn();
const clearLayoutsAndBoundsSpy = vi.fn();
const { useLayoutContext: original } = await vi.importActual<
typeof contextHooks
>('../../hooks/context.tsx');

vi.spyOn(contextHooks, 'useLayoutContext').mockImplementation(() => ({
...original(),
clearLayouts: clearLayoutsSpy
clearLayoutsAndBounds: clearLayoutsAndBoundsSpy
}));

renderWithContext(<ControlsModal />);
Expand All @@ -115,7 +115,7 @@ describe('<ControlsModal />', () => {

await userEvent.click(resetPositionsButton);

expect(clearLayoutsSpy).toHaveBeenCalledOnce();
expect(clearLayoutsAndBoundsSpy).toHaveBeenCalledOnce();
});

it('closes modal using the close button', async () => {
Expand Down
4 changes: 2 additions & 2 deletions gbajs3/src/components/modals/controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ const ControlTabs = ({
resetPositionsButtonId,
setIsSuccessfulSubmit
}: ControlTabsProps) => {
const { clearLayouts } = useLayoutContext();
const { clearLayoutsAndBounds } = useLayoutContext();
const [value, setValue] = useState(0);

const tabIndexToFormId = (tabIndex: number) => {
Expand Down Expand Up @@ -116,7 +116,7 @@ const ControlTabs = ({
<Button
id={resetPositionsButtonId}
sx={{ marginTop: '10px' }}
onClick={clearLayouts}
onClick={clearLayoutsAndBounds}
>
Reset All Positions
</Button>
Expand Down
64 changes: 63 additions & 1 deletion gbajs3/src/components/navigation-menu/navigation-menu.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { screen } from '@testing-library/react';
import { fireEvent, screen } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import * as toast from 'react-hot-toast';
import { describe, expect, it, vi } from 'vitest';
Expand Down Expand Up @@ -433,4 +433,66 @@ describe('<NavigationMenu />', () => {
}
);
});

describe('menu button', () => {
const initialPos = {
clientX: 0,
clientY: 0
};
const movements = [
{ clientX: 0, clientY: 220 },
{ clientX: 0, clientY: 120 }
];

it('sets layout on drag', async () => {
const setLayoutSpy = vi.fn();
const { useLayoutContext: originalLayout, useDragContext: originalDrag } =
await vi.importActual<typeof contextHooks>('../../hooks/context.tsx');

vi.spyOn(contextHooks, 'useDragContext').mockImplementation(() => ({
...originalDrag(),
areItemsDraggable: true
}));

vi.spyOn(contextHooks, 'useLayoutContext').mockImplementation(() => ({
...originalLayout(),
setLayout: setLayoutSpy
}));

renderWithContext(<NavigationMenu />);

fireEvent.mouseDown(screen.getByLabelText('Menu Toggle'), initialPos);
fireEvent.mouseMove(document, movements[0]);
fireEvent.mouseUp(document, movements[1]);

expect(setLayoutSpy).toHaveBeenCalledOnce();
expect(setLayoutSpy).toHaveBeenCalledWith('menuButton', {
position: {
x: movements[1].clientX,
y: movements[1].clientY
}
});
});

it('renders with existing layout', async () => {
const { useLayoutContext: originalLayout, useDragContext: originalDrag } =
await vi.importActual<typeof contextHooks>('../../hooks/context.tsx');

vi.spyOn(contextHooks, 'useDragContext').mockImplementation(() => ({
...originalDrag(),
areItemsDraggable: true
}));

vi.spyOn(contextHooks, 'useLayoutContext').mockImplementation(() => ({
...originalLayout(),
layouts: { menuButton: { position: { x: 0, y: 200 } } }
}));

renderWithContext(<NavigationMenu />);

expect(screen.getByLabelText('Menu Toggle')).toHaveStyle({
transform: 'translate(0px,200px)'
});
});
});
});
54 changes: 42 additions & 12 deletions gbajs3/src/components/navigation-menu/navigation-menu.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useMediaQuery } from '@mui/material';
import { useId, useState } from 'react';
import { useId, useRef, useState } from 'react';
import Draggable from 'react-draggable';
import toast from 'react-hot-toast';
import {
BiInfoCircle,
Expand Down Expand Up @@ -31,7 +32,9 @@ import {
useEmulatorContext,
useAuthContext,
useModalContext,
useRunningContext
useRunningContext,
useDragContext,
useLayoutContext
} from '../../hooks/context.tsx';
import { useQuickReload } from '../../hooks/emulator/use-quick-reload.tsx';
import { useLogout } from '../../hooks/use-logout.tsx';
Expand Down Expand Up @@ -108,7 +111,9 @@ const MenuItemWrapper = styled.ul`
}
`;

const HamburgerButton = styled(ButtonBase)<ExpandableComponentProps>`
const HamburgerButton = styled(ButtonBase)<
ExpandableComponentProps & { $areItemsDraggable: boolean }
>`
background-color: ${({ theme }) => theme.mediumBlack};
color: ${({ theme }) => theme.pureWhite};
z-index: 200;
Expand All @@ -117,6 +122,7 @@ const HamburgerButton = styled(ButtonBase)<ExpandableComponentProps>`
top: 12px;
transition: 0.4s ease-in-out;
-webkit-transition: 0.4s ease-in-out;
transition-property: left;
cursor: pointer;
border-radius: 0.25rem;
border: none;
Expand All @@ -137,6 +143,14 @@ const HamburgerButton = styled(ButtonBase)<ExpandableComponentProps>`
outline: 0;
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
}
${({ $areItemsDraggable = false, theme }) =>
$areItemsDraggable &&
`
outline-color: ${theme.gbaThemeBlue};
outline-style: dashed;
outline-width: 2px;
`}
`;

const NavigationMenuClearDismiss = styled.button`
Expand All @@ -151,11 +165,14 @@ const NavigationMenuClearDismiss = styled.button`

export const NavigationMenu = () => {
const [isExpanded, setIsExpanded] = useState(true);
const menuButtonRef = useRef<HTMLButtonElement | null>(null);
const { setModalContent, setIsModalOpen } = useModalContext();
const { isAuthenticated } = useAuthContext();
const { canvas, emulator } = useEmulatorContext();
const { isRunning } = useRunningContext();
const { execute: executeLogout } = useLogout();
const { areItemsDraggable } = useDragContext();
const { layouts, setLayout } = useLayoutContext();
const theme = useTheme();
const isLargerThanPhone = useMediaQuery(theme.isLargerThanPhone);
const isMobileLandscape = useMediaQuery(theme.isMobileLandscape);
Expand All @@ -169,16 +186,29 @@ export const NavigationMenu = () => {

return (
<>
<HamburgerButton
id="menu-btn"
$isExpanded={isExpanded}
onClick={() => setIsExpanded((prevState) => !prevState)}
aria-label="Menu Toggle"
<Draggable
nodeRef={menuButtonRef}
bounds="parent"
axis="y"
position={layouts?.menuButton?.position ?? { x: 0, y: 0 }}
disabled={!areItemsDraggable}
onStop={(_, data) =>
setLayout('menuButton', { position: { x: 0, y: data.y } })
}
>
<BiMenu
style={{ height: '29px', width: '29px', verticalAlign: 'middle' }}
/>
</HamburgerButton>
<HamburgerButton
ref={menuButtonRef}
id="menu-btn"
$isExpanded={isExpanded}
onClick={() => setIsExpanded((prevState) => !prevState)}
aria-label="Menu Toggle"
$areItemsDraggable={areItemsDraggable}
>
<BiMenu
style={{ height: '29px', width: '29px', verticalAlign: 'middle' }}
/>
</HamburgerButton>
</Draggable>
<NavigationMenuWrapper
data-testid="menu-wrapper"
id="menu-wrapper"
Expand Down
Loading

0 comments on commit a2a4944

Please sign in to comment.