Skip to content

Commit

Permalink
feat(ConfigMenu): Added ConfigMenu component
Browse files Browse the repository at this point in the history
  • Loading branch information
HHogg committed Dec 10, 2023
1 parent f689439 commit 51b432b
Show file tree
Hide file tree
Showing 12 changed files with 590 additions and 3 deletions.
256 changes: 256 additions & 0 deletions workspaces/package/src/ConfigMenu/ConfigMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
import { LucideIcon } from 'lucide-react';
import { PropsWithChildren, useState } from 'react';
import {
TransitionBox,
TransitionBoxProps,
} from '../TransitionBox/TransitionBox';
import Menu from './Menu';
import MenuItemAction from './MenuItemAction';
import MenuItemCheckBox from './MenuItemCheckBox';
import MenuItemNavigate from './MenuItemNavigate';

export interface ConfigMenuProps extends TransitionBoxProps {
config: Config;
onValueChange: (value: Config) => void;
}
export type Config = Record<string, ConfigEntry>;

export type ConfigEntry = {
label: string;
icon: LucideIcon;
config: ConfigEntryValue;
};

export type ConfigEntryValue =
| ConfigBoolean
| ConfigNumber
| ConfigOneOf
| ConfigManyOf
| ConfigAction;

export type ConfigBoolean = {
type: 'boolean';
value: boolean;
labelTrue: string;
labelFalse: string;
};

export type ConfigNumber = {
type: 'number';
value: number;
min: number;
max: number;
step: number;
formatter: (value: number) => string;
};

export type ConfigOneOf = {
type: 'oneOf';
value: string;
options: string[];
};

export type ConfigManyOf = {
type: 'manyOf';
values: string[];
options: string[];
};

export type ConfigAction = {
type: 'action';
label: string;
action: () => void;
};

const isBoolean = (value: ConfigEntryValue): value is ConfigBoolean =>
value.type === 'boolean';

const isNumber = (value: ConfigEntryValue): value is ConfigNumber =>
value.type === 'number';

const isOneOf = (value: ConfigEntryValue): value is ConfigOneOf =>
value.type === 'oneOf';

const isManyOf = (value: ConfigEntryValue): value is ConfigManyOf =>
value.type === 'manyOf';

const isAction = (value: ConfigEntryValue): value is ConfigAction =>
value.type === 'action';

const getLabel = (entry: ConfigEntry) => {
switch (entry.config.type) {
case 'boolean':
return entry.config.value
? entry.config.labelTrue
: entry.config.labelFalse;
case 'number':
case 'oneOf':
return entry.config.value;
case 'manyOf':
if (entry.config.values.length === 0) {
return 'None';
} else if (entry.config.values.length < entry.config.options.length) {
return 'Some';
} else {
return 'All';
}
case 'action':
return entry.config.label;
}
};

const updateConfig = (
config: Config,
key: string,
updates: object
): Config => ({
...config,
[key]: {
...config[key],
config: {
...config[key].config,
...updates,
},
},
});

const toggleValueInArray = (array: string[], value: string) => {
if (array.includes(value)) {
return array.filter((v) => v !== value);
} else {
return [...array, value];
}
};

const __root = '__root';

export const ConfigMenu = ({
config,
onValueChange,
...rest
}: PropsWithChildren<ConfigMenuProps>) => {
const [activeKey, setActiveKey] = useState(__root);
const activeEntry = activeKey === __root ? undefined : config[activeKey];

const createUpdateHandler = (key: string, updates: object) => () => {
onValueChange(updateConfig(config, key, updates));

if (!isManyOf(config[key].config)) {
setActiveKey(__root);
}
};

return (
<TransitionBox
animation="Pop"
backgroundColor="black"
borderRadius="x2"
minWidth="200px"
paddingVertical="x2"
textColor="text-shade-1"
theme="night"
{...rest}
>
{activeKey === __root && (
<Menu title="Settings">
{Object.keys(config).map((key) => (
<MenuItemNavigate
Icon={config[key].icon}
key={key}
onClick={() => setActiveKey(key)}
title={config[key].label}
value={getLabel(config[key])}
/>
))}
</Menu>
)}

{activeEntry && activeKey !== __root && (
<Menu onBack={() => setActiveKey(__root)} title={activeEntry.label}>
{isBoolean(activeEntry.config) && (
<>
<MenuItemCheckBox
checked={activeEntry.config.value === true}
onClick={createUpdateHandler(activeKey, { value: true })}
>
{activeEntry.config.labelTrue}
</MenuItemCheckBox>

<MenuItemCheckBox
checked={activeEntry.config.value === false}
onClick={createUpdateHandler(activeKey, { value: false })}
>
{activeEntry.config.labelFalse}
</MenuItemCheckBox>
</>
)}

{isNumber(activeEntry.config) &&
Array.from({
length: Math.floor(
(activeEntry.config.max - activeEntry.config.min) /
activeEntry.config.step
),
}).map(
(_, i) =>
isNumber(activeEntry.config) && (
<MenuItemCheckBox
checked={activeEntry.config.value === i}
key={i}
onClick={createUpdateHandler(activeKey, { value: i })}
>
{activeEntry.config.formatter(i)}
</MenuItemCheckBox>
)
)}

{isOneOf(activeEntry.config) &&
activeEntry.config.options.map(
(option) =>
isOneOf(activeEntry.config) && (
<MenuItemCheckBox
checked={activeEntry.config.value === option}
key={option}
onClick={createUpdateHandler(activeKey, { value: option })}
>
{option}
</MenuItemCheckBox>
)
)}

{isManyOf(activeEntry.config) &&
activeEntry.config.options.map(
(option) =>
isManyOf(activeEntry.config) && (
<MenuItemCheckBox
checked={activeEntry.config.values.includes(option)}
key={option}
onClick={createUpdateHandler(activeKey, {
values: toggleValueInArray(
activeEntry.config.values,
option
),
})}
>
{option}
</MenuItemCheckBox>
)
)}

{isAction(activeEntry.config) && (
<MenuItemAction
Icon={activeEntry.icon}
title={activeEntry.config.label}
onClick={() => {
if (isAction(activeEntry.config)) {
activeEntry.config.action();
setActiveKey(__root);
}
}}
/>
)}
</Menu>
)}
</TransitionBox>
);
};
52 changes: 52 additions & 0 deletions workspaces/package/src/ConfigMenu/Menu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { ChevronLeftIcon } from 'lucide-react';
import { PropsWithChildren } from 'react';
import { Appear } from '../Appear/Appear';
import { Box } from '../Box/Box';
import { Button } from '../Button/Button';
import { Text } from '../Text/Text';

type ContextMenuProps = {
onBack?: () => void;
title: string;
};

export default function Menu({
children,
onBack,
title,
}: PropsWithChildren<ContextMenuProps>) {
return (
<Appear animation="FadeSlideRight" delay={100} visible>
<Box
alignChildrenVertical="middle"
borderBottom
borderColor="background-shade-3"
flex="horizontal"
gap="x3"
paddingBottom="x2"
paddingHorizontal="x3"
>
{onBack && (
<Box>
<Button
flex="vertical"
padding="x1"
onClick={() => onBack()}
variant="tertiary"
>
<ChevronLeftIcon size="1rem" />
</Button>
</Box>
)}

<Box grow>
<Text weight="x2">{title}</Text>
</Box>

<Box width="1rem" />
</Box>

<Box margin="x2">{children}</Box>
</Appear>
);
}
33 changes: 33 additions & 0 deletions workspaces/package/src/ConfigMenu/MenuItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { PropsWithChildren } from 'react';
import { Button, ButtonProps } from '../Button/Button';
import { Text } from '../Text/Text';

export type MenuItemProps = ButtonProps & {};

export default function MenuItem({
children,
...rest
}: PropsWithChildren<MenuItemProps>) {
return (
<Button
{...rest}
align="start"
alignChildren="start"
borderRadius="x0"
variant="tertiary"
weight="x1"
width="100%"
>
<Text
alignChildrenHorizontal="start"
alignChildrenVertical="middle"
flex="horizontal"
gap="x2"
grow
size="x3"
>
{children}
</Text>
</Button>
);
}
29 changes: 29 additions & 0 deletions workspaces/package/src/ConfigMenu/MenuItemAction.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { LucideIcon } from 'lucide-react';
import { Box } from '../Box/Box';
import { Text } from '../Text/Text';
import MenuItem, { MenuItemProps } from './MenuItem';

type MenuItemNavigateProps = MenuItemProps & {
Icon: LucideIcon;
title: string;
};

export default function MenuItemAction({
Icon,
title,
...rest
}: MenuItemNavigateProps) {
return (
<MenuItem {...rest}>
<Box>
<Icon size="1rem" />
</Box>

<Box grow minWidth="120px">
<Text weight="x2">{title}</Text>
</Box>

<Box>{/* <ChevronRightIcon size="1rem" /> */}</Box>
</MenuItem>
);
}
Loading

0 comments on commit 51b432b

Please sign in to comment.