Skip to content

Commit fb7af6a

Browse files
authored
Added a splitbutton with dropdown. (#100)
1 parent b715ce5 commit fb7af6a

File tree

4 files changed

+286
-0
lines changed

4 files changed

+286
-0
lines changed
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import { ChevronDownIcon } from "lucide-react";
2+
import { useCallback, useMemo, useRef, useState } from "react";
3+
import {
4+
Button,
5+
ButtonGroup,
6+
ButtonGroupProps,
7+
ButtonProps,
8+
ClickAwayListener,
9+
Grow,
10+
MenuItem,
11+
MenuList,
12+
Paper,
13+
Popper,
14+
} from "@mui/material";
15+
16+
interface Option {
17+
label: string; // Display text for the option
18+
key: string; // Unique identifier for the option, used for event handling
19+
buttonProps?: ButtonProps; // Optional props for the button, allowing customization like icons, styles, etc.
20+
}
21+
22+
interface SplitButtonOptionProps extends Omit<ButtonGroupProps, "onClick"> {
23+
options: Option[]; // Array of options for the dropdown
24+
onClick: (
25+
option: Option,
26+
evt: React.MouseEvent<HTMLButtonElement, MouseEvent>
27+
) => void; // Callback when an option is selected
28+
btnProps?: ButtonProps; // Additional props for the main button
29+
defaultSelectedIndex?: number; // Optional default selected index
30+
}
31+
32+
/**
33+
* A split button component that combines a primary action button with a dropdown menu of additional options.
34+
*
35+
* Features:
36+
* - Customizable button and dropdown options
37+
* - Keyboard and mouse accessibility
38+
* - Smooth dropdown animations
39+
* - Type-safe props and event handling
40+
*/
41+
export function SplitButtonOptions({
42+
options,
43+
onClick,
44+
defaultSelectedIndex = 0,
45+
...props
46+
}: SplitButtonOptionProps) {
47+
const anchorRef = useRef<HTMLDivElement>(null);
48+
const [open, setOpen] = useState(false);
49+
const [selectedIdx, setSelectedIdx] = useState(defaultSelectedIndex);
50+
51+
// Memoize selected option to prevent unnecessary re-renders
52+
const selectedOption = useMemo(() => options[selectedIdx], [options, selectedIdx]);
53+
54+
/**
55+
* Handle menu item selection
56+
*/
57+
const handleMenuItemClick = useCallback(
58+
(event: React.MouseEvent<HTMLLIElement, MouseEvent>, index: number) => {
59+
setSelectedIdx(index);
60+
setOpen(false);
61+
},
62+
[]
63+
);
64+
65+
/**
66+
* Toggle dropdown visibility
67+
*/
68+
const handleToggle = useCallback(() => {
69+
setOpen((prevOpen) => !prevOpen);
70+
}, []);
71+
72+
/**
73+
* Close dropdown when clicking outside
74+
*/
75+
const handleClose = useCallback((event: MouseEvent | TouchEvent) => {
76+
if (anchorRef.current?.contains(event.target as Node)) {
77+
return;
78+
}
79+
setOpen(false);
80+
}, []);
81+
82+
/**
83+
* Handle main button click
84+
*/
85+
const handleMainButtonClick = useCallback(
86+
(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
87+
onClick(selectedOption, event);
88+
handleClose(event.nativeEvent);
89+
},
90+
[onClick, selectedOption, handleClose]
91+
);
92+
93+
return (
94+
<>
95+
<ButtonGroup
96+
variant="contained"
97+
ref={anchorRef}
98+
aria-label="split button"
99+
{...props}
100+
>
101+
<Button {...selectedOption.buttonProps} onClick={handleMainButtonClick}>
102+
{selectedOption.label}
103+
</Button>
104+
<Button
105+
onClick={handleToggle}
106+
size="small"
107+
aria-label="select action"
108+
aria-haspopup="menu"
109+
aria-expanded={open}
110+
aria-controls="split-button-menu"
111+
>
112+
<ChevronDownIcon />
113+
</Button>
114+
</ButtonGroup>
115+
116+
<Popper
117+
sx={(theme) => ({
118+
zIndex: 1,
119+
width: `calc(${anchorRef.current?.clientWidth}px - ${theme.spacing(1)})`, // Adjust width to fit within the button group
120+
maxWidth: `calc(${anchorRef.current?.clientWidth}px - ${theme.spacing(1)})`, // Ensure max width matches the button group
121+
overflow: "hidden",
122+
})}
123+
open={open}
124+
anchorEl={anchorRef.current}
125+
role={undefined}
126+
transition
127+
disablePortal
128+
>
129+
{({ TransitionProps, placement }) => (
130+
<Grow
131+
{...TransitionProps}
132+
style={{
133+
transformOrigin:
134+
placement === "bottom" ? "center top" : "center bottom",
135+
}}
136+
>
137+
<Paper
138+
sx={{
139+
borderTopLeftRadius: 0,
140+
borderTopRightRadius: 0,
141+
}}
142+
>
143+
<ClickAwayListener onClickAway={handleClose}>
144+
<MenuList id="split-button-menu" autoFocusItem>
145+
{options.map((option, index) => (
146+
<MenuItem
147+
key={option.key}
148+
selected={index === selectedIdx}
149+
onClick={(event) =>
150+
handleMenuItemClick(event, index)
151+
}
152+
sx={{
153+
display: "flex",
154+
alignItems: "center",
155+
gap: 1,
156+
}}
157+
>
158+
<span
159+
style={{
160+
display: "flex",
161+
alignItems: "center",
162+
}}
163+
>
164+
{option.buttonProps?.startIcon}
165+
</span>
166+
<span style={{ flexGrow: 1 }}>
167+
{option.label}
168+
</span>
169+
<span style={{ flexShrink: 0 }}>
170+
{option.buttonProps?.endIcon}
171+
</span>
172+
</MenuItem>
173+
))}
174+
</MenuList>
175+
</ClickAwayListener>
176+
</Paper>
177+
</Grow>
178+
)}
179+
</Popper>
180+
</>
181+
);
182+
}

frontend/src/routeTree.gen.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { Route as InboxTaskTaskIdImport } from './routes/inbox/task.$taskId'
2626
import { Route as InboxFolderPathImport } from './routes/inbox/folder.$path'
2727
import { Route as DebugDesignLoadingImport } from './routes/debug/design/loading'
2828
import { Route as DebugDesignIconsImport } from './routes/debug/design/icons'
29+
import { Route as DebugDesignButtonsImport } from './routes/debug/design/buttons'
2930
import { Route as LibraryBrowseArtistsRouteImport } from './routes/library/browse/artists.route'
3031
import { Route as LibraryBrowseArtistsIndexImport } from './routes/library/browse/artists.index'
3132
import { Route as LibraryBrowseArtistsArtistImport } from './routes/library/browse/artists.$artist'
@@ -130,6 +131,12 @@ const DebugDesignIconsRoute = DebugDesignIconsImport.update({
130131
getParentRoute: () => rootRoute,
131132
} as any)
132133

134+
const DebugDesignButtonsRoute = DebugDesignButtonsImport.update({
135+
id: '/debug/design/buttons',
136+
path: '/debug/design/buttons',
137+
getParentRoute: () => rootRoute,
138+
} as any)
139+
133140
const LibraryBrowseArtistsRouteRoute = LibraryBrowseArtistsRouteImport.update({
134141
id: '/library/browse/artists',
135142
path: '/library/browse/artists',
@@ -279,6 +286,13 @@ declare module '@tanstack/react-router' {
279286
preLoaderRoute: typeof LibraryBrowseArtistsRouteImport
280287
parentRoute: typeof rootRoute
281288
}
289+
'/debug/design/buttons': {
290+
id: '/debug/design/buttons'
291+
path: '/debug/design/buttons'
292+
fullPath: '/debug/design/buttons'
293+
preLoaderRoute: typeof DebugDesignButtonsImport
294+
parentRoute: typeof rootRoute
295+
}
282296
'/debug/design/icons': {
283297
id: '/debug/design/icons'
284298
path: '/debug/design/icons'
@@ -462,6 +476,7 @@ export interface FileRoutesByFullPath {
462476
'/sessiondraft': typeof SessiondraftIndexRoute
463477
'/terminal': typeof TerminalIndexRoute
464478
'/library/browse/artists': typeof LibraryBrowseArtistsRouteRouteWithChildren
479+
'/debug/design/buttons': typeof DebugDesignButtonsRoute
465480
'/debug/design/icons': typeof DebugDesignIconsRoute
466481
'/debug/design/loading': typeof DebugDesignLoadingRoute
467482
'/inbox/folder/$path': typeof InboxFolderPathRoute
@@ -490,6 +505,7 @@ export interface FileRoutesByTo {
490505
'/inbox': typeof InboxIndexRoute
491506
'/sessiondraft': typeof SessiondraftIndexRoute
492507
'/terminal': typeof TerminalIndexRoute
508+
'/debug/design/buttons': typeof DebugDesignButtonsRoute
493509
'/debug/design/icons': typeof DebugDesignIconsRoute
494510
'/debug/design/loading': typeof DebugDesignLoadingRoute
495511
'/inbox/folder/$path': typeof InboxFolderPathRoute
@@ -519,6 +535,7 @@ export interface FileRoutesById {
519535
'/sessiondraft/': typeof SessiondraftIndexRoute
520536
'/terminal/': typeof TerminalIndexRoute
521537
'/library/browse/artists': typeof LibraryBrowseArtistsRouteRouteWithChildren
538+
'/debug/design/buttons': typeof DebugDesignButtonsRoute
522539
'/debug/design/icons': typeof DebugDesignIconsRoute
523540
'/debug/design/loading': typeof DebugDesignLoadingRoute
524541
'/inbox/folder/$path': typeof InboxFolderPathRoute
@@ -550,6 +567,7 @@ export interface FileRouteTypes {
550567
| '/sessiondraft'
551568
| '/terminal'
552569
| '/library/browse/artists'
570+
| '/debug/design/buttons'
553571
| '/debug/design/icons'
554572
| '/debug/design/loading'
555573
| '/inbox/folder/$path'
@@ -577,6 +595,7 @@ export interface FileRouteTypes {
577595
| '/inbox'
578596
| '/sessiondraft'
579597
| '/terminal'
598+
| '/debug/design/buttons'
580599
| '/debug/design/icons'
581600
| '/debug/design/loading'
582601
| '/inbox/folder/$path'
@@ -604,6 +623,7 @@ export interface FileRouteTypes {
604623
| '/sessiondraft/'
605624
| '/terminal/'
606625
| '/library/browse/artists'
626+
| '/debug/design/buttons'
607627
| '/debug/design/icons'
608628
| '/debug/design/loading'
609629
| '/inbox/folder/$path'
@@ -634,6 +654,7 @@ export interface RootRouteChildren {
634654
SessiondraftIndexRoute: typeof SessiondraftIndexRoute
635655
TerminalIndexRoute: typeof TerminalIndexRoute
636656
LibraryBrowseArtistsRouteRoute: typeof LibraryBrowseArtistsRouteRouteWithChildren
657+
DebugDesignButtonsRoute: typeof DebugDesignButtonsRoute
637658
DebugDesignIconsRoute: typeof DebugDesignIconsRoute
638659
DebugDesignLoadingRoute: typeof DebugDesignLoadingRoute
639660
InboxFolderPathRoute: typeof InboxFolderPathRoute
@@ -656,6 +677,7 @@ const rootRouteChildren: RootRouteChildren = {
656677
SessiondraftIndexRoute: SessiondraftIndexRoute,
657678
TerminalIndexRoute: TerminalIndexRoute,
658679
LibraryBrowseArtistsRouteRoute: LibraryBrowseArtistsRouteRouteWithChildren,
680+
DebugDesignButtonsRoute: DebugDesignButtonsRoute,
659681
DebugDesignIconsRoute: DebugDesignIconsRoute,
660682
DebugDesignLoadingRoute: DebugDesignLoadingRoute,
661683
InboxFolderPathRoute: InboxFolderPathRoute,
@@ -689,6 +711,7 @@ export const routeTree = rootRoute
689711
"/sessiondraft/",
690712
"/terminal/",
691713
"/library/browse/artists",
714+
"/debug/design/buttons",
692715
"/debug/design/icons",
693716
"/debug/design/loading",
694717
"/inbox/folder/$path",
@@ -734,6 +757,9 @@ export const routeTree = rootRoute
734757
"/library/browse/artists/"
735758
]
736759
},
760+
"/debug/design/buttons": {
761+
"filePath": "debug/design/buttons.tsx"
762+
},
737763
"/debug/design/icons": {
738764
"filePath": "debug/design/icons.tsx"
739765
},
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { EyeIcon, PencilIcon } from "lucide-react";
2+
import { Box, Typography, useTheme } from "@mui/material";
3+
import { createFileRoute } from "@tanstack/react-router";
4+
5+
import { SplitButtonOptions } from "@/components/common/inputs/splitButton";
6+
import { PageWrapper } from "@/components/common/page";
7+
8+
export const Route = createFileRoute("/debug/design/buttons")({
9+
component: RouteComponent,
10+
});
11+
12+
function RouteComponent() {
13+
const theme = useTheme();
14+
return (
15+
<PageWrapper sx={{ gap: "1rem", display: "flex", flexDirection: "column" }}>
16+
<Box>
17+
<Typography
18+
variant="h1"
19+
gutterBottom
20+
sx={{ textAlign: "center", fontSize: "2rem", fontWeight: "bold" }}
21+
>
22+
Buttons
23+
</Typography>
24+
<Typography variant="body1">
25+
This page was used to design and test button styles. It contains
26+
various button styles and their states. It is mainly used for
27+
testing and debugging purposes.
28+
</Typography>
29+
</Box>
30+
<Box>
31+
<Typography
32+
variant="h2"
33+
gutterBottom
34+
sx={{ fontSize: "1.5rem", fontWeight: "bold" }}
35+
>
36+
Button with dropdown menu
37+
</Typography>
38+
<Typography variant="body1">
39+
A multi state button with a dropdown menu. It can be used to select
40+
different actions for the button. Used in inbox.
41+
</Typography>
42+
<Box
43+
sx={{
44+
display: "flex",
45+
justifyContent: "center",
46+
marginTop: "1rem",
47+
}}
48+
>
49+
<SplitButtonOptions
50+
options={[
51+
{
52+
label: "Action 1",
53+
key: "action1",
54+
buttonProps: {
55+
startIcon: <PencilIcon size={theme.iconSize.md} />,
56+
},
57+
},
58+
{
59+
label: "Action 2",
60+
key: "action2",
61+
buttonProps: {
62+
startIcon: <EyeIcon size={theme.iconSize.md} />,
63+
},
64+
},
65+
{ label: "Action 3", key: "action3" },
66+
]}
67+
onClick={(option, evt) => {
68+
alert(`Clicked on ${option.label}`);
69+
}}
70+
/>
71+
</Box>
72+
</Box>
73+
</PageWrapper>
74+
);
75+
}

frontend/src/routes/debug/index.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ function RouteComponent() {
2929
<Link to="/debug/design/loading">
3030
<li>Loading states</li>
3131
</Link>
32+
<Link to="/debug/design/buttons">
33+
<li>Buttons</li>
34+
</Link>
3235
</Box>
3336
</Box>
3437
<Box>

0 commit comments

Comments
 (0)