diff --git a/frontend/src/components/common/inputs/splitButton.tsx b/frontend/src/components/common/inputs/splitButton.tsx new file mode 100644 index 00000000..d4f05048 --- /dev/null +++ b/frontend/src/components/common/inputs/splitButton.tsx @@ -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 { + options: Option[]; // Array of options for the dropdown + onClick: ( + option: Option, + evt: React.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(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, 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) => { + onClick(selectedOption, event); + handleClose(event.nativeEvent); + }, + [onClick, selectedOption, handleClose] + ); + + return ( + <> + + + + + + ({ + 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 }) => ( + + + + + {options.map((option, index) => ( + + handleMenuItemClick(event, index) + } + sx={{ + display: "flex", + alignItems: "center", + gap: 1, + }} + > + + {option.buttonProps?.startIcon} + + + {option.label} + + + {option.buttonProps?.endIcon} + + + ))} + + + + + )} + + + ); +} diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 9a0b3bf5..7beb187c 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -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' @@ -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', @@ -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' @@ -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 @@ -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 @@ -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 @@ -550,6 +567,7 @@ export interface FileRouteTypes { | '/sessiondraft' | '/terminal' | '/library/browse/artists' + | '/debug/design/buttons' | '/debug/design/icons' | '/debug/design/loading' | '/inbox/folder/$path' @@ -577,6 +595,7 @@ export interface FileRouteTypes { | '/inbox' | '/sessiondraft' | '/terminal' + | '/debug/design/buttons' | '/debug/design/icons' | '/debug/design/loading' | '/inbox/folder/$path' @@ -604,6 +623,7 @@ export interface FileRouteTypes { | '/sessiondraft/' | '/terminal/' | '/library/browse/artists' + | '/debug/design/buttons' | '/debug/design/icons' | '/debug/design/loading' | '/inbox/folder/$path' @@ -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 @@ -656,6 +677,7 @@ const rootRouteChildren: RootRouteChildren = { SessiondraftIndexRoute: SessiondraftIndexRoute, TerminalIndexRoute: TerminalIndexRoute, LibraryBrowseArtistsRouteRoute: LibraryBrowseArtistsRouteRouteWithChildren, + DebugDesignButtonsRoute: DebugDesignButtonsRoute, DebugDesignIconsRoute: DebugDesignIconsRoute, DebugDesignLoadingRoute: DebugDesignLoadingRoute, InboxFolderPathRoute: InboxFolderPathRoute, @@ -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", @@ -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" }, diff --git a/frontend/src/routes/debug/design/buttons.tsx b/frontend/src/routes/debug/design/buttons.tsx new file mode 100644 index 00000000..e48d3138 --- /dev/null +++ b/frontend/src/routes/debug/design/buttons.tsx @@ -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 ( + + + + Buttons + + + 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. + + + + + Button with dropdown menu + + + A multi state button with a dropdown menu. It can be used to select + different actions for the button. Used in inbox. + + + , + }, + }, + { + label: "Action 2", + key: "action2", + buttonProps: { + startIcon: , + }, + }, + { label: "Action 3", key: "action3" }, + ]} + onClick={(option, evt) => { + alert(`Clicked on ${option.label}`); + }} + /> + + + + ); +} diff --git a/frontend/src/routes/debug/index.tsx b/frontend/src/routes/debug/index.tsx index 7a64bfba..6cc23d6b 100644 --- a/frontend/src/routes/debug/index.tsx +++ b/frontend/src/routes/debug/index.tsx @@ -29,6 +29,9 @@ function RouteComponent() {
  • Loading states
  • + +
  • Buttons
  • +