Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 182 additions & 0 deletions frontend/src/components/common/inputs/splitButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { ChevronDownIcon } from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "react";
import {
Button,
ButtonGroup,
ButtonGroupProps,
ButtonProps,
ClickAwayListener,
Grow,
MenuItem,
MenuList,
Paper,
Popper,
} from "@mui/material";

interface Option {
label: string; // Display text for the option
key: string; // Unique identifier for the option, used for event handling
buttonProps?: ButtonProps; // Optional props for the button, allowing customization like icons, styles, etc.
}

interface SplitButtonOptionProps extends Omit<ButtonGroupProps, "onClick"> {
options: Option[]; // Array of options for the dropdown
onClick: (
option: Option,
evt: React.MouseEvent<HTMLButtonElement, MouseEvent>
) => void; // Callback when an option is selected
btnProps?: ButtonProps; // Additional props for the main button
defaultSelectedIndex?: number; // Optional default selected index
}

/**
* A split button component that combines a primary action button with a dropdown menu of additional options.
*
* Features:
* - Customizable button and dropdown options
* - Keyboard and mouse accessibility
* - Smooth dropdown animations
* - Type-safe props and event handling
*/
export function SplitButtonOptions({
options,
onClick,
defaultSelectedIndex = 0,
...props
}: SplitButtonOptionProps) {
const anchorRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [selectedIdx, setSelectedIdx] = useState(defaultSelectedIndex);

// Memoize selected option to prevent unnecessary re-renders
const selectedOption = useMemo(() => options[selectedIdx], [options, selectedIdx]);

/**
* Handle menu item selection
*/
const handleMenuItemClick = useCallback(
(event: React.MouseEvent<HTMLLIElement, MouseEvent>, index: number) => {
setSelectedIdx(index);
setOpen(false);
},
[]
);

/**
* Toggle dropdown visibility
*/
const handleToggle = useCallback(() => {
setOpen((prevOpen) => !prevOpen);
}, []);

/**
* Close dropdown when clicking outside
*/
const handleClose = useCallback((event: MouseEvent | TouchEvent) => {
if (anchorRef.current?.contains(event.target as Node)) {
return;
}
setOpen(false);
}, []);

/**
* Handle main button click
*/
const handleMainButtonClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
onClick(selectedOption, event);
handleClose(event.nativeEvent);
},
[onClick, selectedOption, handleClose]
);

return (
<>
<ButtonGroup
variant="contained"
ref={anchorRef}
aria-label="split button"
{...props}
>
<Button {...selectedOption.buttonProps} onClick={handleMainButtonClick}>
{selectedOption.label}
</Button>
<Button
onClick={handleToggle}
size="small"
aria-label="select action"
aria-haspopup="menu"
aria-expanded={open}
aria-controls="split-button-menu"
>
<ChevronDownIcon />
</Button>
</ButtonGroup>

<Popper
sx={(theme) => ({
zIndex: 1,
width: `calc(${anchorRef.current?.clientWidth}px - ${theme.spacing(1)})`, // Adjust width to fit within the button group
maxWidth: `calc(${anchorRef.current?.clientWidth}px - ${theme.spacing(1)})`, // Ensure max width matches the button group
overflow: "hidden",
})}
open={open}
anchorEl={anchorRef.current}
role={undefined}
transition
disablePortal
>
{({ TransitionProps, placement }) => (
<Grow
{...TransitionProps}
style={{
transformOrigin:
placement === "bottom" ? "center top" : "center bottom",
}}
>
<Paper
sx={{
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
}}
>
<ClickAwayListener onClickAway={handleClose}>
<MenuList id="split-button-menu" autoFocusItem>
{options.map((option, index) => (
<MenuItem
key={option.key}
selected={index === selectedIdx}
onClick={(event) =>
handleMenuItemClick(event, index)
}
sx={{
display: "flex",
alignItems: "center",
gap: 1,
}}
>
<span
style={{
display: "flex",
alignItems: "center",
}}
>
{option.buttonProps?.startIcon}
</span>
<span style={{ flexGrow: 1 }}>
{option.label}
</span>
<span style={{ flexShrink: 0 }}>
{option.buttonProps?.endIcon}
</span>
</MenuItem>
))}
</MenuList>
</ClickAwayListener>
</Paper>
</Grow>
)}
</Popper>
</>
);
}
26 changes: 26 additions & 0 deletions frontend/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { Route as InboxTaskTaskIdImport } from './routes/inbox/task.$taskId'
import { Route as InboxFolderPathImport } from './routes/inbox/folder.$path'
import { Route as DebugDesignLoadingImport } from './routes/debug/design/loading'
import { Route as DebugDesignIconsImport } from './routes/debug/design/icons'
import { Route as DebugDesignButtonsImport } from './routes/debug/design/buttons'
import { Route as LibraryBrowseArtistsRouteImport } from './routes/library/browse/artists.route'
import { Route as LibraryBrowseArtistsIndexImport } from './routes/library/browse/artists.index'
import { Route as LibraryBrowseArtistsArtistImport } from './routes/library/browse/artists.$artist'
Expand Down Expand Up @@ -130,6 +131,12 @@ const DebugDesignIconsRoute = DebugDesignIconsImport.update({
getParentRoute: () => rootRoute,
} as any)

const DebugDesignButtonsRoute = DebugDesignButtonsImport.update({
id: '/debug/design/buttons',
path: '/debug/design/buttons',
getParentRoute: () => rootRoute,
} as any)

const LibraryBrowseArtistsRouteRoute = LibraryBrowseArtistsRouteImport.update({
id: '/library/browse/artists',
path: '/library/browse/artists',
Expand Down Expand Up @@ -279,6 +286,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LibraryBrowseArtistsRouteImport
parentRoute: typeof rootRoute
}
'/debug/design/buttons': {
id: '/debug/design/buttons'
path: '/debug/design/buttons'
fullPath: '/debug/design/buttons'
preLoaderRoute: typeof DebugDesignButtonsImport
parentRoute: typeof rootRoute
}
'/debug/design/icons': {
id: '/debug/design/icons'
path: '/debug/design/icons'
Expand Down Expand Up @@ -462,6 +476,7 @@ export interface FileRoutesByFullPath {
'/sessiondraft': typeof SessiondraftIndexRoute
'/terminal': typeof TerminalIndexRoute
'/library/browse/artists': typeof LibraryBrowseArtistsRouteRouteWithChildren
'/debug/design/buttons': typeof DebugDesignButtonsRoute
'/debug/design/icons': typeof DebugDesignIconsRoute
'/debug/design/loading': typeof DebugDesignLoadingRoute
'/inbox/folder/$path': typeof InboxFolderPathRoute
Expand Down Expand Up @@ -490,6 +505,7 @@ export interface FileRoutesByTo {
'/inbox': typeof InboxIndexRoute
'/sessiondraft': typeof SessiondraftIndexRoute
'/terminal': typeof TerminalIndexRoute
'/debug/design/buttons': typeof DebugDesignButtonsRoute
'/debug/design/icons': typeof DebugDesignIconsRoute
'/debug/design/loading': typeof DebugDesignLoadingRoute
'/inbox/folder/$path': typeof InboxFolderPathRoute
Expand Down Expand Up @@ -519,6 +535,7 @@ export interface FileRoutesById {
'/sessiondraft/': typeof SessiondraftIndexRoute
'/terminal/': typeof TerminalIndexRoute
'/library/browse/artists': typeof LibraryBrowseArtistsRouteRouteWithChildren
'/debug/design/buttons': typeof DebugDesignButtonsRoute
'/debug/design/icons': typeof DebugDesignIconsRoute
'/debug/design/loading': typeof DebugDesignLoadingRoute
'/inbox/folder/$path': typeof InboxFolderPathRoute
Expand Down Expand Up @@ -550,6 +567,7 @@ export interface FileRouteTypes {
| '/sessiondraft'
| '/terminal'
| '/library/browse/artists'
| '/debug/design/buttons'
| '/debug/design/icons'
| '/debug/design/loading'
| '/inbox/folder/$path'
Expand Down Expand Up @@ -577,6 +595,7 @@ export interface FileRouteTypes {
| '/inbox'
| '/sessiondraft'
| '/terminal'
| '/debug/design/buttons'
| '/debug/design/icons'
| '/debug/design/loading'
| '/inbox/folder/$path'
Expand Down Expand Up @@ -604,6 +623,7 @@ export interface FileRouteTypes {
| '/sessiondraft/'
| '/terminal/'
| '/library/browse/artists'
| '/debug/design/buttons'
| '/debug/design/icons'
| '/debug/design/loading'
| '/inbox/folder/$path'
Expand Down Expand Up @@ -634,6 +654,7 @@ export interface RootRouteChildren {
SessiondraftIndexRoute: typeof SessiondraftIndexRoute
TerminalIndexRoute: typeof TerminalIndexRoute
LibraryBrowseArtistsRouteRoute: typeof LibraryBrowseArtistsRouteRouteWithChildren
DebugDesignButtonsRoute: typeof DebugDesignButtonsRoute
DebugDesignIconsRoute: typeof DebugDesignIconsRoute
DebugDesignLoadingRoute: typeof DebugDesignLoadingRoute
InboxFolderPathRoute: typeof InboxFolderPathRoute
Expand All @@ -656,6 +677,7 @@ const rootRouteChildren: RootRouteChildren = {
SessiondraftIndexRoute: SessiondraftIndexRoute,
TerminalIndexRoute: TerminalIndexRoute,
LibraryBrowseArtistsRouteRoute: LibraryBrowseArtistsRouteRouteWithChildren,
DebugDesignButtonsRoute: DebugDesignButtonsRoute,
DebugDesignIconsRoute: DebugDesignIconsRoute,
DebugDesignLoadingRoute: DebugDesignLoadingRoute,
InboxFolderPathRoute: InboxFolderPathRoute,
Expand Down Expand Up @@ -689,6 +711,7 @@ export const routeTree = rootRoute
"/sessiondraft/",
"/terminal/",
"/library/browse/artists",
"/debug/design/buttons",
"/debug/design/icons",
"/debug/design/loading",
"/inbox/folder/$path",
Expand Down Expand Up @@ -734,6 +757,9 @@ export const routeTree = rootRoute
"/library/browse/artists/"
]
},
"/debug/design/buttons": {
"filePath": "debug/design/buttons.tsx"
},
"/debug/design/icons": {
"filePath": "debug/design/icons.tsx"
},
Expand Down
75 changes: 75 additions & 0 deletions frontend/src/routes/debug/design/buttons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { EyeIcon, PencilIcon } from "lucide-react";
import { Box, Typography, useTheme } from "@mui/material";
import { createFileRoute } from "@tanstack/react-router";

import { SplitButtonOptions } from "@/components/common/inputs/splitButton";
import { PageWrapper } from "@/components/common/page";

export const Route = createFileRoute("/debug/design/buttons")({
component: RouteComponent,
});

function RouteComponent() {
const theme = useTheme();
return (
<PageWrapper sx={{ gap: "1rem", display: "flex", flexDirection: "column" }}>
<Box>
<Typography
variant="h1"
gutterBottom
sx={{ textAlign: "center", fontSize: "2rem", fontWeight: "bold" }}
>
Buttons
</Typography>
<Typography variant="body1">
This page was used to design and test button styles. It contains
various button styles and their states. It is mainly used for
testing and debugging purposes.
</Typography>
</Box>
<Box>
<Typography
variant="h2"
gutterBottom
sx={{ fontSize: "1.5rem", fontWeight: "bold" }}
>
Button with dropdown menu
</Typography>
<Typography variant="body1">
A multi state button with a dropdown menu. It can be used to select
different actions for the button. Used in inbox.
</Typography>
<Box
sx={{
display: "flex",
justifyContent: "center",
marginTop: "1rem",
}}
>
<SplitButtonOptions
options={[
{
label: "Action 1",
key: "action1",
buttonProps: {
startIcon: <PencilIcon size={theme.iconSize.md} />,
},
},
{
label: "Action 2",
key: "action2",
buttonProps: {
startIcon: <EyeIcon size={theme.iconSize.md} />,
},
},
{ label: "Action 3", key: "action3" },
]}
onClick={(option, evt) => {

Check warning on line 67 in frontend/src/routes/debug/design/buttons.tsx

View workflow job for this annotation

GitHub Actions / Javascript checks (21)

'evt' is defined but never used. Allowed unused args must match /^_/u
alert(`Clicked on ${option.label}`);
}}
/>
</Box>
</Box>
</PageWrapper>
);
}
3 changes: 3 additions & 0 deletions frontend/src/routes/debug/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ function RouteComponent() {
<Link to="/debug/design/loading">
<li>Loading states</li>
</Link>
<Link to="/debug/design/buttons">
<li>Buttons</li>
</Link>
</Box>
</Box>
<Box>
Expand Down
Loading