From cce3bfa5ec40794b7fcb91f28ac0027dbab1630c Mon Sep 17 00:00:00 2001
From: Nicholas VanCise <40526638+thenick775@users.noreply.github.com>
Date: Fri, 20 Sep 2024 18:21:26 -0700
Subject: [PATCH] feat: control profiles (#173)
* feat: controls profile form
- wip control profile form form poc
* feat: wip design
- not sure this is the best application of this style of button list
* feat: show behavior
* feat: edit name
- not sure I like this iteration, but a decent idea
* feat: tentative edit impl
* feat: editable profile name
* feat: remove debug info, fix form submits
* feat: hoist shared icon
* feat: independent profiles with same name
* fix: zooming behavior
* feat: tests
---
gbajs3/package-lock.json | 46 +++-
gbajs3/package.json | 1 +
gbajs3/src/components/controls/consts.tsx | 1 +
.../controls/control-panel.spec.tsx | 1 +
gbajs3/src/components/modals/cheats.tsx | 7 +-
.../src/components/modals/controls.spec.tsx | 34 ++-
gbajs3/src/components/modals/controls.tsx | 51 +++-
.../modals/controls/control-profiles.spec.tsx | 137 ++++++++++
.../modals/controls/control-profiles.tsx | 242 ++++++++++++++++++
gbajs3/src/components/modals/save-states.tsx | 9 +-
gbajs3/src/components/shared/styled.tsx | 6 +
gbajs3/src/context/layout/layout.tsx | 8 +-
gbajs3/src/hooks/use-layouts.tsx | 10 +-
13 files changed, 509 insertions(+), 44 deletions(-)
create mode 100644 gbajs3/src/components/modals/controls/control-profiles.spec.tsx
create mode 100644 gbajs3/src/components/modals/controls/control-profiles.tsx
diff --git a/gbajs3/package-lock.json b/gbajs3/package-lock.json
index 741f3376..f7a1e0f9 100644
--- a/gbajs3/package-lock.json
+++ b/gbajs3/package-lock.json
@@ -14,6 +14,7 @@
"@mui/x-tree-view": "^7.0.0",
"@uidotdev/usehooks": "^2.4.1",
"jwt-decode": "^4.0.0",
+ "nanoid": "^5.0.7",
"react": "^18.2.0",
"react-animate-height": "^3.2.2",
"react-dom": "^18.2.0",
@@ -8177,9 +8178,9 @@
}
},
"node_modules/nanoid": {
- "version": "3.3.7",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
- "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
+ "version": "5.0.7",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.7.tgz",
+ "integrity": "sha512-oLxFY2gd2IqnjcYyOXD8XGCftpGtZP2AbHbOkthDkvRywH5ayNtPVy9YlOPcHckXzbLTCHpkb7FB+yuxKV13pQ==",
"funding": [
{
"type": "github",
@@ -8187,10 +8188,10 @@
}
],
"bin": {
- "nanoid": "bin/nanoid.cjs"
+ "nanoid": "bin/nanoid.js"
},
"engines": {
- "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ "node": "^18 || >=20"
}
},
"node_modules/natural-compare": {
@@ -8599,6 +8600,23 @@
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="
},
+ "node_modules/postcss/node_modules/nanoid": {
+ "version": "3.3.7",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
+ "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -10620,6 +10638,24 @@
}
}
},
+ "node_modules/vite/node_modules/nanoid": {
+ "version": "3.3.7",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
+ "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
"node_modules/vite/node_modules/postcss": {
"version": "8.4.47",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz",
diff --git a/gbajs3/package.json b/gbajs3/package.json
index 5b96e088..fc1a7b5c 100644
--- a/gbajs3/package.json
+++ b/gbajs3/package.json
@@ -19,6 +19,7 @@
"@mui/x-tree-view": "^7.0.0",
"@uidotdev/usehooks": "^2.4.1",
"jwt-decode": "^4.0.0",
+ "nanoid": "^5.0.7",
"react": "^18.2.0",
"react-animate-height": "^3.2.2",
"react-dom": "^18.2.0",
diff --git a/gbajs3/src/components/controls/consts.tsx b/gbajs3/src/components/controls/consts.tsx
index d1d4bc67..2cf207d0 100644
--- a/gbajs3/src/components/controls/consts.tsx
+++ b/gbajs3/src/components/controls/consts.tsx
@@ -1,2 +1,3 @@
export const saveStateSlotLocalStorageKey = 'currentSaveStateSlot';
export const virtualControlsLocalStorageKey = 'areVirtualControlsEnabled';
+export const virtualControlProfilesLocalStorageKey = 'virtualControlProfiles';
diff --git a/gbajs3/src/components/controls/control-panel.spec.tsx b/gbajs3/src/components/controls/control-panel.spec.tsx
index b3d16a00..6cb56550 100644
--- a/gbajs3/src/components/controls/control-panel.spec.tsx
+++ b/gbajs3/src/components/controls/control-panel.spec.tsx
@@ -166,6 +166,7 @@ describe('', () => {
const testLayout = {
clearLayouts: vi.fn(),
setLayout: setLayoutSpy,
+ setLayouts: vi.fn(),
hasSetLayout: true,
layouts: { screen: { initialBounds: new DOMRect() } }
};
diff --git a/gbajs3/src/components/modals/cheats.tsx b/gbajs3/src/components/modals/cheats.tsx
index faa14d16..b3339f78 100644
--- a/gbajs3/src/components/modals/cheats.tsx
+++ b/gbajs3/src/components/modals/cheats.tsx
@@ -1,7 +1,6 @@
import { Button, IconButton, TextField, useMediaQuery } from '@mui/material';
import { useCallback, useId, useMemo, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
-import { BiPlus } from 'react-icons/bi';
import { CiSquareRemove } from 'react-icons/ci';
import { styled, useTheme } from 'styled-components';
@@ -15,6 +14,7 @@ import {
} from '../product-tour/embedded-product-tour.tsx';
import { CircleCheckButton } from '../shared/circle-check-button.tsx';
import { ManagedCheckbox } from '../shared/managed-checkbox.tsx';
+import { StyledBiPlus } from '../shared/styled.tsx';
type OptionallyHiddenProps = {
$shouldHide: boolean;
@@ -54,11 +54,6 @@ const StyledCiSquareRemove = styled(CiSquareRemove)`
min-width: 40px;
`;
-const StyledBiPlus = styled(BiPlus)`
- width: 25px;
- height: 25px;
-`;
-
const CheatsFormSeparator = styled.div`
display: flex;
flex-direction: column;
diff --git a/gbajs3/src/components/modals/controls.spec.tsx b/gbajs3/src/components/modals/controls.spec.tsx
index 14fbe162..c2792805 100644
--- a/gbajs3/src/components/modals/controls.spec.tsx
+++ b/gbajs3/src/components/modals/controls.spec.tsx
@@ -15,6 +15,9 @@ describe('', () => {
expect(
screen.getByRole('tab', { name: 'Virtual Controls', selected: true })
).toBeVisible();
+ expect(
+ screen.getByRole('tab', { name: 'Profiles', selected: false })
+ ).toBeVisible();
expect(
screen.getByRole('tab', { name: 'Key Bindings', selected: false })
).toBeVisible();
@@ -27,12 +30,34 @@ describe('', () => {
screen.getByRole('button', { name: 'Save Changes' }).getAttribute('form')
);
+ // select control profiles
+ await userEvent.click(screen.getByRole('tab', { name: 'Profiles' }));
+
+ expect(
+ screen.getByRole('tab', { name: 'Profiles', selected: true })
+ ).toBeVisible();
+ expect(
+ screen.getByRole('tab', { name: 'Key Bindings', selected: false })
+ ).toBeVisible();
+ expect(
+ screen.getByRole('tab', { name: 'Virtual Controls', selected: false })
+ ).toBeVisible();
+
+ expect(screen.getByRole('list', { name: 'Profiles List' })).toBeVisible();
+ // note: save changes button is not shown on control profiles tab
+ expect(
+ screen.queryByRole('button', { name: 'Save Changes' })
+ ).not.toBeInTheDocument();
+
// select key bindings form
await userEvent.click(screen.getByRole('tab', { name: 'Key Bindings' }));
expect(
screen.getByRole('tab', { name: 'Key Bindings', selected: true })
).toBeVisible();
+ expect(
+ screen.getByRole('tab', { name: 'Profiles', selected: false })
+ ).toBeVisible();
expect(
screen.getByRole('tab', { name: 'Virtual Controls', selected: false })
).toBeVisible();
@@ -53,15 +78,18 @@ describe('', () => {
expect(
screen.getByRole('tab', { name: 'Virtual Controls', selected: true })
).toBeVisible();
+ expect(
+ screen.getByRole('tab', { name: 'Profiles', selected: false })
+ ).toBeVisible();
expect(
screen.getByRole('tab', { name: 'Key Bindings', selected: false })
).toBeVisible();
- const virtualControlsFormRenavigate = screen.getByRole('form', {
+ const virtualControlsFormReNavigate = screen.getByRole('form', {
name: 'Virtual Controls Form'
});
- expect(virtualControlsFormRenavigate).toBeVisible();
- expect(virtualControlsFormRenavigate.id).toEqual(
+ expect(virtualControlsFormReNavigate).toBeVisible();
+ expect(virtualControlsFormReNavigate.id).toEqual(
screen.getByRole('button', { name: 'Save Changes' }).getAttribute('form')
);
});
diff --git a/gbajs3/src/components/modals/controls.tsx b/gbajs3/src/components/modals/controls.tsx
index 1a54806f..6b405a8f 100644
--- a/gbajs3/src/components/modals/controls.tsx
+++ b/gbajs3/src/components/modals/controls.tsx
@@ -13,6 +13,7 @@ import {
type TourSteps
} from '../product-tour/embedded-product-tour.tsx';
import { CircleCheckButton } from '../shared/circle-check-button.tsx';
+import { ControlProfiles } from './controls/control-profiles.tsx';
type TabPanelProps = {
children: ReactNode;
@@ -23,6 +24,7 @@ type TabPanelProps = {
type ControlTabsProps = {
setFormId: Dispatch>;
virtualControlsFormId: string;
+ controlProfilesFormId: string;
keyBindingsFormId: string;
resetPositionsButtonId: string;
setIsSuccessfulSubmit: (successfulSubmit: boolean) => void;
@@ -31,6 +33,10 @@ type ControlTabsProps = {
const TabsWithBorder = styled(Tabs)`
border-bottom: 1px solid;
border-color: rgba(0, 0, 0, 0.12);
+
+ & .MuiTabs-scrollButtons {
+ width: fit-content;
+ }
`;
const TabWrapper = styled.div`
@@ -60,6 +66,7 @@ const TabPanel = ({ children, index, value }: TabPanelProps) => {
const ControlTabs = ({
setFormId,
virtualControlsFormId,
+ controlProfilesFormId,
keyBindingsFormId,
resetPositionsButtonId,
setIsSuccessfulSubmit
@@ -67,9 +74,22 @@ const ControlTabs = ({
const { clearLayouts } = useLayoutContext();
const [value, setValue] = useState(0);
- const handleTabChange = (_: React.SyntheticEvent, newValue: number) => {
- setValue(newValue);
- setFormId(newValue === 0 ? virtualControlsFormId : keyBindingsFormId);
+ const tabIndexToFormId = (tabIndex: number) => {
+ switch (tabIndex) {
+ case 0:
+ return virtualControlsFormId;
+ case 1:
+ return controlProfilesFormId;
+ case 2:
+ return keyBindingsFormId;
+ default:
+ return virtualControlsFormId;
+ }
+ };
+
+ const handleTabChange = (_: React.SyntheticEvent, tabIndex: number) => {
+ setValue(tabIndex);
+ setFormId(tabIndexToFormId(tabIndex));
setIsSuccessfulSubmit(false);
};
@@ -78,12 +98,15 @@ const ControlTabs = ({
return (
<>
-
+
+
+
+
+
>
@@ -175,19 +201,22 @@ export const ControlsModal = () => {
-
+ {formId !== `${baseId}--control-profiles` && (
+
+ )}
diff --git a/gbajs3/src/components/modals/controls/control-profiles.spec.tsx b/gbajs3/src/components/modals/controls/control-profiles.spec.tsx
new file mode 100644
index 00000000..8c676c1f
--- /dev/null
+++ b/gbajs3/src/components/modals/controls/control-profiles.spec.tsx
@@ -0,0 +1,137 @@
+import { screen, waitFor } from '@testing-library/react';
+import { userEvent } from '@testing-library/user-event';
+import { describe, expect, it, vi } from 'vitest';
+
+import { ControlProfiles } from './control-profiles.tsx';
+import { renderWithContext } from '../../../../test/render-with-context.tsx';
+import * as contextHooks from '../../../hooks/context.tsx';
+import { virtualControlProfilesLocalStorageKey } from '../../controls/consts.tsx';
+
+describe('', () => {
+ it('renders if there are no profiles', () => {
+ renderWithContext();
+
+ expect(screen.getByText('No control profiles'));
+ expect(
+ screen.getByRole('button', { name: 'Create New Profile' })
+ ).toBeVisible();
+ });
+
+ it('renders profiles from storage', async () => {
+ localStorage.setItem(
+ virtualControlProfilesLocalStorageKey,
+ '[{"id":"testId1","name":"Profile-1","layouts":{"screen":{"initialBounds":{"x":260,"y":15,"width":834,"height":600.09375,"top":15,"right":1094,"bottom":615.09375,"left":260}},"controlPanel":{"initialBounds":{"x":260,"y":620,"width":584,"height":60,"top":620,"right":844,"bottom":680,"left":260}}},"active":true},{"id":"testId2","name":"Profile-2","layouts":{"screen":{"initialBounds":{"x":260,"y":15,"width":834,"height":600.09375,"top":15,"right":1094,"bottom":615.09375,"left":260}},"controlPanel":{"initialBounds":{"x":260,"y":620,"width":584,"height":60,"top":620,"right":844,"bottom":680,"left":260}}},"active":true}]'
+ );
+
+ renderWithContext();
+
+ // isValid from RHF updates state, but its not visible as we are not editing, we need to wait for it
+ await waitFor(() =>
+ expect(
+ screen.getByRole('button', { name: "Edit Profile-1's name" })
+ ).toHaveAttribute('data-is-valid', 'true')
+ );
+
+ expect(screen.getByRole('button', { name: 'Profile-1' })).toBeVisible();
+ expect(screen.getByRole('button', { name: 'Profile-2' })).toBeVisible();
+ expect(screen.getByRole('button', { name: "Edit Profile-1's name" }));
+ expect(screen.getByRole('button', { name: "Edit Profile-2's name" }));
+ expect(screen.getByRole('button', { name: 'Delete Profile-1' }));
+ expect(screen.getByRole('button', { name: 'Delete Profile-2' }));
+ });
+
+ it('loads a profile', async () => {
+ localStorage.setItem(
+ virtualControlProfilesLocalStorageKey,
+ '[{"id":"testId1","name":"Profile-1","layouts":{"screen":{"initialBounds":{"x":260,"y":15,"width":834,"height":600.09375,"top":15,"right":1094,"bottom":615.09375,"left":260}},"controlPanel":{"initialBounds":{"x":260,"y":620,"width":584,"height":60,"top":620,"right":844,"bottom":680,"left":260}}},"active":true}]'
+ );
+
+ const setLayoutsSpy = vi.fn();
+
+ const { useLayoutContext: originalLayout } = await vi.importActual<
+ typeof contextHooks
+ >('../../../hooks/context.tsx');
+
+ vi.spyOn(contextHooks, 'useLayoutContext').mockImplementation(() => ({
+ ...originalLayout(),
+ setLayouts: setLayoutsSpy
+ }));
+
+ renderWithContext();
+
+ await userEvent.click(screen.getByRole('button', { name: 'Profile-1' }));
+
+ expect(setLayoutsSpy).toHaveBeenCalledWith({
+ controlPanel: expect.anything(),
+ screen: expect.anything()
+ });
+ });
+
+ it('adds new profiles', async () => {
+ const setItemSpy = vi.spyOn(Storage.prototype, 'setItem');
+
+ renderWithContext();
+
+ await userEvent.click(screen.getByLabelText('Create New Profile'));
+
+ expect(screen.getByRole('button', { name: 'Profile-1' }));
+
+ expect(setItemSpy).toHaveBeenCalledWith(
+ virtualControlProfilesLocalStorageKey,
+ expect.stringMatching(
+ /\[{"id":".*","name":"Profile-1","layouts":{},"active":true}\]/
+ )
+ );
+
+ await userEvent.click(screen.getByLabelText('Create New Profile'));
+
+ expect(screen.getByRole('button', { name: 'Profile-2' }));
+
+ expect(setItemSpy).toHaveBeenCalledWith(
+ virtualControlProfilesLocalStorageKey,
+ expect.stringMatching(
+ /\[{"id":".*","name":"Profile-1","layouts":{},"active":true},{"id":".*","name":"Profile-2","layouts":{},"active":true}\]/
+ )
+ );
+ });
+
+ it('updates a profile name', async () => {
+ const setItemSpy = vi.spyOn(Storage.prototype, 'setItem');
+ localStorage.setItem(
+ virtualControlProfilesLocalStorageKey,
+ '[{"id":"testId1","name":"Profile-1","layouts":{"screen":{"initialBounds":{"x":260,"y":15,"width":834,"height":600.09375,"top":15,"right":1094,"bottom":615.09375,"left":260}},"controlPanel":{"initialBounds":{"x":260,"y":620,"width":584,"height":60,"top":620,"right":844,"bottom":680,"left":260}}},"active":true},{"id":"testId2","name":"Profile-2","layouts":{"screen":{"initialBounds":{"x":260,"y":15,"width":834,"height":600.09375,"top":15,"right":1094,"bottom":615.09375,"left":260}},"controlPanel":{"initialBounds":{"x":260,"y":620,"width":584,"height":60,"top":620,"right":844,"bottom":680,"left":260}}},"active":true}]'
+ );
+
+ renderWithContext();
+
+ await userEvent.click(
+ screen.getByRole('button', { name: "Edit Profile-1's name" })
+ );
+
+ await userEvent.type(screen.getByRole('textbox'), '-edited');
+
+ await userEvent.click(
+ screen.getByRole('button', { name: "Save Profile-1's name" })
+ );
+
+ expect(screen.getByRole('button', { name: 'Profile-1-edited' }));
+
+ expect(setItemSpy).toHaveBeenCalledWith(
+ virtualControlProfilesLocalStorageKey,
+ expect.stringMatching(/\[{"id":"testId1","name":"Profile-1-edited".*\]/)
+ );
+ });
+
+ it('deletes a profile', async () => {
+ localStorage.setItem(
+ virtualControlProfilesLocalStorageKey,
+ '[{"id":"testId1","name":"Profile-1","layouts":{"screen":{"initialBounds":{"x":260,"y":15,"width":834,"height":600.09375,"top":15,"right":1094,"bottom":615.09375,"left":260}},"controlPanel":{"initialBounds":{"x":260,"y":620,"width":584,"height":60,"top":620,"right":844,"bottom":680,"left":260}}},"active":true}]'
+ );
+
+ renderWithContext();
+
+ await userEvent.click(screen.getByLabelText('Delete Profile-1'));
+
+ expect(screen.getByText('No control profiles'));
+ });
+});
diff --git a/gbajs3/src/components/modals/controls/control-profiles.tsx b/gbajs3/src/components/modals/controls/control-profiles.tsx
new file mode 100644
index 00000000..e80f8f98
--- /dev/null
+++ b/gbajs3/src/components/modals/controls/control-profiles.tsx
@@ -0,0 +1,242 @@
+import { IconButton, TextField } from '@mui/material';
+import { useLocalStorage } from '@uidotdev/usehooks';
+import { nanoid } from 'nanoid';
+import { useState } from 'react';
+import { useForm } from 'react-hook-form';
+import { BiTrash, BiEdit, BiSave } from 'react-icons/bi';
+import { styled } from 'styled-components';
+
+import { useLayoutContext } from '../../../hooks/context.tsx';
+import { virtualControlProfilesLocalStorageKey } from '../../controls/consts.tsx';
+import { CenteredText, StyledBiPlus } from '../../shared/styled.tsx';
+
+import type { Layouts } from '../../../context/layout/layout.tsx';
+import type { IconButtonProps } from '@mui/material';
+import type { ReactNode } from 'react';
+
+type ControlProfilesProps = {
+ id: string;
+};
+
+type VirtualControlProfile = {
+ id: string;
+ name: string;
+ active: boolean;
+ layouts: Layouts;
+};
+
+type VirtualControlProfiles = VirtualControlProfile[];
+
+type StatefulIconButtonProps = {
+ condition: boolean;
+ truthyIcon: ReactNode;
+ falsyIcon: ReactNode;
+} & IconButtonProps;
+
+type EditableProfileLoadButtonProps = {
+ name: string;
+ loadProfile: () => void;
+ onSubmit: ({ name }: { name: string }) => void;
+};
+
+const StyledLi = styled.li`
+ cursor: pointer;
+ display: grid;
+ grid-template-columns: auto 32px;
+ gap: 10px;
+
+ color: ${({ theme }) => theme.blueCharcoal};
+ background-color: ${({ theme }) => theme.pureWhite};
+ border: 1px solid rgba(0, 0, 0, 0.125);
+`;
+
+const ProfilesList = styled.ul`
+ list-style-type: none;
+ display: flex;
+ flex-direction: column;
+ margin: 0;
+ padding: 0;
+
+ & > ${StyledLi}:first-child {
+ border-top-left-radius: 4px;
+ border-top-right-radius: 4px;
+ }
+
+ & > ${StyledLi}:last-child {
+ border-bottom-left-radius: 4px;
+ border-bottom-right-radius: 4px;
+ }
+
+ & > ${StyledLi}:not(:first-child) {
+ border-top-width: 0;
+ }
+`;
+
+const StyledCiCircleRemove = styled(BiTrash)`
+ height: 100%;
+ width: 20px;
+`;
+
+const StyledBiEdit = styled(BiEdit)`
+ height: 100%;
+ width: 20px;
+`;
+
+const StyledBiSave = styled(BiSave)`
+ height: 100%;
+ width: 20px;
+`;
+
+const LoadProfileButton = styled.button`
+ padding: 0.5rem 0.5rem;
+ width: 100%;
+ color: ${({ theme }) => theme.blueCharcoal};
+ background-color: ${({ theme }) => theme.pureWhite};
+ border: none;
+ text-align: left;
+ font-size: 16px;
+ height: 32px;
+
+ &:hover {
+ color: ${({ theme }) => theme.darkGrayBlue};
+ background-color: ${({ theme }) => theme.aliceBlue1};
+ }
+`;
+
+const StyledForm = styled.form`
+ display: flex;
+ gap: 10px;
+`;
+
+const StatefulIconButton = ({
+ condition,
+ truthyIcon,
+ falsyIcon,
+ ...rest
+}: StatefulIconButtonProps) => (
+
+ {condition ? truthyIcon : falsyIcon}
+
+);
+
+const EditableProfileLoadButton = ({
+ name,
+ loadProfile,
+ onSubmit
+}: EditableProfileLoadButtonProps) => {
+ const [isEditing, setIsEditing] = useState(false);
+ const {
+ register,
+ handleSubmit,
+ formState: { errors, isValid }
+ } = useForm<{ name: string }>({
+ defaultValues: {
+ name: name
+ }
+ });
+
+ return (
+
+ {isEditing ? (
+
+ ) : (
+ {name}
+ )}
+ }
+ falsyIcon={}
+ aria-label={`${isEditing ? 'Save' : 'Edit'} ${name}'s name`}
+ type="submit"
+ data-is-valid={isValid}
+ onClick={() => isValid && setIsEditing((prevState) => !prevState)}
+ />
+
+ );
+};
+
+export const ControlProfiles = ({ id }: ControlProfilesProps) => {
+ const [virtualControlProfiles, setVirtualControlProfiles] = useLocalStorage<
+ VirtualControlProfiles | undefined
+ >(virtualControlProfilesLocalStorageKey);
+ const { layouts, setLayouts } = useLayoutContext();
+
+ const addProfile = () => {
+ setVirtualControlProfiles((prevState) => [
+ ...(prevState ?? []),
+ {
+ id: nanoid(),
+ name: `Profile-${(prevState?.length ?? 0) + 1}`,
+ layouts: layouts,
+ active: true
+ }
+ ]);
+ };
+
+ const updateProfile = (id: string, updatedName: string) => {
+ setVirtualControlProfiles((prevState) =>
+ prevState?.map((profile) => {
+ if (profile.id == id)
+ return {
+ ...profile,
+ name: updatedName
+ };
+
+ return profile;
+ })
+ );
+ };
+
+ const deleteProfile = (id: string) => {
+ setVirtualControlProfiles((prevState) =>
+ prevState?.filter((p) => p.id !== id)
+ );
+ };
+
+ return (
+ <>
+
+ {virtualControlProfiles?.map?.(
+ (profile: VirtualControlProfile, idx: number) => (
+
+ setLayouts(profile.layouts)}
+ onSubmit={({ name }) => updateProfile(profile.id, name)}
+ />
+ deleteProfile(profile.id)}
+ >
+
+
+
+ )
+ )}
+ {!virtualControlProfiles?.length && (
+
+ No control profiles
+
+ )}
+
+ addProfile()}
+ >
+
+
+ >
+ );
+};
diff --git a/gbajs3/src/components/modals/save-states.tsx b/gbajs3/src/components/modals/save-states.tsx
index 4eb3f9c6..091abfc0 100644
--- a/gbajs3/src/components/modals/save-states.tsx
+++ b/gbajs3/src/components/modals/save-states.tsx
@@ -2,7 +2,7 @@ import { Button, IconButton, TextField } from '@mui/material';
import { useLocalStorage } from '@uidotdev/usehooks';
import { useCallback, useEffect, useId, useState } from 'react';
import { useForm, type SubmitHandler } from 'react-hook-form';
-import { BiError, BiPlus, BiTrash } from 'react-icons/bi';
+import { BiError, BiTrash } from 'react-icons/bi';
import { styled, useTheme } from 'styled-components';
import { ModalBody } from './modal-body.tsx';
@@ -16,7 +16,7 @@ import {
} from '../product-tour/embedded-product-tour.tsx';
import { CircleCheckButton } from '../shared/circle-check-button.tsx';
import { ErrorWithIcon } from '../shared/error-with-icon.tsx';
-import { CenteredText } from '../shared/styled.tsx';
+import { CenteredText, StyledBiPlus } from '../shared/styled.tsx';
type InputProps = {
saveStateSlot: number;
@@ -69,11 +69,6 @@ const SaveStatesList = styled.ul`
}
`;
-const StyledBiPlus = styled(BiPlus)`
- width: 25px;
- height: 25px;
-`;
-
const StyledCiCircleRemove = styled(BiTrash)`
height: 100%;
width: 20px;
diff --git a/gbajs3/src/components/shared/styled.tsx b/gbajs3/src/components/shared/styled.tsx
index c68e3797..d23ef9a4 100644
--- a/gbajs3/src/components/shared/styled.tsx
+++ b/gbajs3/src/components/shared/styled.tsx
@@ -1,3 +1,4 @@
+import { BiPlus } from 'react-icons/bi';
import { styled } from 'styled-components';
export const CenteredTextContainer = styled.div`
@@ -35,3 +36,8 @@ export const FooterWrapper = styled.div`
border-top: 1px solid ${({ theme }) => theme.pattensBlue};
padding: 1rem 1rem;
`;
+
+export const StyledBiPlus = styled(BiPlus)`
+ width: 25px;
+ height: 25px;
+`;
diff --git a/gbajs3/src/context/layout/layout.tsx b/gbajs3/src/context/layout/layout.tsx
index f9803602..e57da53d 100644
--- a/gbajs3/src/context/layout/layout.tsx
+++ b/gbajs3/src/context/layout/layout.tsx
@@ -2,13 +2,13 @@ import { createContext, useCallback, useEffect, type ReactNode } from 'react';
import { useLayouts } from '../../hooks/use-layouts.tsx';
-type Layout = {
+export type Layout = {
position?: { x: number; y: number };
size?: { width: string | number; height: string | number };
initialBounds?: DOMRect;
};
-type Layouts = {
+export type Layouts = {
[key: string]: Layout;
};
@@ -17,6 +17,7 @@ type LayoutContextProps = {
hasSetLayout: boolean;
clearLayouts: () => void;
setLayout: (layoutKey: string, layout: Layout) => void;
+ setLayouts: (layouts: Layouts) => void;
};
type LayoutProviderProps = { children: ReactNode };
@@ -53,7 +54,8 @@ export const LayoutProvider = ({ children }: LayoutProviderProps) => {
layouts,
hasSetLayout,
clearLayouts,
- setLayout
+ setLayout,
+ setLayouts
}}
>
{children}
diff --git a/gbajs3/src/hooks/use-layouts.tsx b/gbajs3/src/hooks/use-layouts.tsx
index 2ea627cd..ef43afa6 100644
--- a/gbajs3/src/hooks/use-layouts.tsx
+++ b/gbajs3/src/hooks/use-layouts.tsx
@@ -1,15 +1,7 @@
import { useLocalStorage } from '@uidotdev/usehooks';
import { useCallback, useMemo } from 'react';
-type Layout = {
- position?: { x: number; y: number };
- size?: { width: string | number; height: string | number };
- initialBounds?: DOMRect;
-};
-
-type Layouts = {
- [key: string]: Layout;
-};
+import type { Layouts } from '../context/layout/layout';
const layoutLocalStorageKey = 'componentLayouts';