Skip to content

Commit

Permalink
Add Dropdown menu component and support for custom shortcuts (#72)
Browse files Browse the repository at this point in the history
* Add dropdown menu component

* Use dropdown menu styles on ContextMenu

* Add ContextMenu.Shortcut alias for DropdownMenuShortcut

* Add createShortcutHandler utility

* Add shortcuts support for Category and Links

* Remove test dropdown component

* Move shortcut  helpers to its own separate module
  • Loading branch information
BrianUribe6 authored Feb 7, 2024
1 parent ae36791 commit 4474195
Show file tree
Hide file tree
Showing 9 changed files with 436 additions and 25 deletions.
1 change: 1 addition & 0 deletions apps/front-end/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-context-menu": "^2.1.3",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-select": "^1.2.1",
"@radix-ui/react-toolbar": "^1.0.3",
Expand Down
25 changes: 13 additions & 12 deletions apps/front-end/src/components/ContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import clsx from "clsx";
import React, { PropsWithChildren, forwardRef } from "react";
import {
DropdownMenuShortcut,
contentStyles,
itemStyles,
menuSeparatorStyles,
} from "./DropdownMenu";
import clsx from "clsx";

export type ContextMenuProps = PropsWithChildren;
export type IconProps = PropsWithChildren<{ label?: string }>;
Expand All @@ -18,11 +24,7 @@ const Option = forwardRef<HTMLDivElement, OptionProps>(function Option(
<ContextMenuPrimitive.Item
ref={ref}
{...props}
className={clsx(
className,
"flex select-none items-center rounded-md px-2 py-1 data-[highlighted]:bg-gray-200/75",
"dark:data-[highlighted]:bg-slate-800 dark:data-[highlighted]:text-cyan-300",
)}
className={itemStyles({ className })}
>
{children}
</ContextMenuPrimitive.Item>
Expand All @@ -34,7 +36,7 @@ function Icon({ children, label }: IconProps) {
return (
<>
{React.cloneElement(Child as React.ReactElement, {
className: "mr-3 text-lg",
className: "mr-2 h-4 w-4",
"aria-hidden": true,
focusable: false,
})}
Expand All @@ -44,18 +46,16 @@ function Icon({ children, label }: IconProps) {
}

function Divider() {
return (
<ContextMenuPrimitive.Separator className="m-1 h-0.5 bg-gray-200 dark:bg-slate-600" />
);
return <ContextMenuPrimitive.Separator className={menuSeparatorStyles()} />;
}

const Content = forwardRef<
HTMLDivElement,
ContextMenuPrimitive.MenuContentProps
>(function Body({ children, ...props }, ref) {
>(function Body({ children, className, ...props }, ref) {
return (
<ContextMenuPrimitive.Content
className="min-w-[220px] overflow-hidden rounded-lg bg-gray-50 p-2 drop-shadow-md data-[disabled]:text-gray-500 dark:bg-slate-700 z-50"
className={clsx(contentStyles({ className }), "w-56")}
ref={ref}
{...props}
>
Expand All @@ -70,4 +70,5 @@ export default Object.assign(ContextMenu, {
Divider,
Icon,
Trigger: ContextMenuPrimitive.Trigger,
Shortcut: DropdownMenuShortcut,
});
271 changes: 271 additions & 0 deletions apps/front-end/src/components/DropdownMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
"use client";

import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import {
MdCheck as Check,
MdChevronRight as ChevronRight,
MdCircle as Circle,
} from "react-icons/md";
import clsx from "clsx";
import { cva } from "class-variance-authority";
import {
ElementRef,
ComponentPropsWithoutRef,
forwardRef,
HTMLAttributes,
} from "react";

const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;

type DropdownMenuSubTriggerRef = ElementRef<
typeof DropdownMenuPrimitive.SubTrigger
>;

type DropdownMenuSubTriggerProps = ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.SubTrigger
> & {
inset?: boolean;
};

const DropdownMenuSubTrigger = forwardRef<
DropdownMenuSubTriggerRef,
DropdownMenuSubTriggerProps
>(function DropdownMenuSubTrigger(
{ className, inset, children, ...props },
ref,
) {
return (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={clsx(itemStyles(), inset && "pl-8", className)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
);
});

type DropdownMenuSubContentRef = ElementRef<
typeof DropdownMenuPrimitive.SubContent
>;

type DrodownMenuSubContentProps = ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.SubContent
>;

const DropdownMenuSubContent = forwardRef<
DropdownMenuSubContentRef,
DrodownMenuSubContentProps
>(function DropdownMenuSubContent({ className, ...props }, ref) {
return (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={clsx(contentStyles({ className }))}
{...props}
/>
);
});

export const contentStyles = cva([
"dark:text-inheritz-50 min-w-[8rem] overflow-hidden rounded-md border border-slate-600 p-1 shadow-md dark:bg-slate-700",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
"data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
]);

type DropdownMenuContentRef = ElementRef<typeof DropdownMenuPrimitive.Content>;

type DroddownMenuContentProps = ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.Content
>;

const DropdownMenuContent = forwardRef<
DropdownMenuContentRef,
DroddownMenuContentProps
>(function DropdownMenuContent({ className, sideOffset = 4, ...props }, ref) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={contentStyles({ className })}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
});

export const itemStyles = cva([
"relative flex cursor-default select-none focus:bg-cyan-800 focus:text-cyan-300",
"items-center rounded-md px-2 py-1.5 text-base outline-none transition-colors",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
]);

type DropdownMenuItemRef = ElementRef<typeof DropdownMenuPrimitive.Item>;

type DropdownMenuItemProps = ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.Item
> & {
inset?: boolean;
};

const DropdownMenuItem = forwardRef<DropdownMenuItemRef, DropdownMenuItemProps>(
function DropdownMenuItem({ className, inset, ...props }, ref) {
return (
<DropdownMenuPrimitive.Item
ref={ref}
className={clsx(itemStyles({ className }), inset && "pl-8")}
{...props}
/>
);
},
);

export const checkboxItemStyles = cva([itemStyles(), "pl-8 pr-2"]);

type DropdownMenuCheckboxItemRef = ElementRef<
typeof DropdownMenuPrimitive.CheckboxItem
>;

type DropdownMenuCheckboxItemProps = ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.CheckboxItem
>;

const DropdownMenuCheckboxItem = forwardRef<
DropdownMenuCheckboxItemRef,
DropdownMenuCheckboxItemProps
>(function DropdownMenuCheckboxItem(
{ className, children, checked, ...props },
ref,
) {
return (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={checkboxItemStyles({ className })}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
});

export const radioStyles = cva([
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm",
"py-1.5 pl-8 pr-2 text-base outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
]);

type DropdownMenuRadioItemRef = ElementRef<
typeof DropdownMenuPrimitive.RadioItem
>;

type DropdownMenuRadioItemProps = ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.RadioItem
>;

const DropdownMenuRadioItem = forwardRef<
DropdownMenuRadioItemRef,
DropdownMenuRadioItemProps
>(function DropdownMenuRadioItem({ className, children, ...props }, ref) {
return (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={radioStyles({ className })}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
});

type DropdownMenuLabelRef = ElementRef<typeof DropdownMenuPrimitive.Label>;

type DropdownMenuLabelProps = ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.Label
> & {
inset?: boolean;
};
export const menuLabelStyles = cva("px-2 py-1.5 text-base font-semibold");

const DropdownMenuLabel = forwardRef<
DropdownMenuLabelRef,
DropdownMenuLabelProps
>(function DropdownMenuLabelRef({ className, inset, ...props }, ref) {
return (
<DropdownMenuPrimitive.Label
ref={ref}
className={clsx(menuLabelStyles(), className, inset && "pl-8")}
{...props}
/>
);
});

export const menuSeparatorStyles = cva("-mx-1 my-1 h-px bg-slate-600");
type DropdownMenuSeparatorRef = ElementRef<
typeof DropdownMenuPrimitive.Separator
>;

type DropdownMenuSeparatorProps = ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.Separator
>;

const DropdownMenuSeparator = forwardRef<
DropdownMenuSeparatorRef,
DropdownMenuSeparatorProps
>(function DropdownMenuSeparator({ className, ...props }, ref) {
return (
<DropdownMenuPrimitive.Separator
ref={ref}
className={menuSeparatorStyles({ className })}
{...props}
/>
);
});

type DropdownMenuShortcutProps = HTMLAttributes<HTMLSpanElement>;

export const shortcutStyles = cva("ml-auto text-sm tracking-widest opacity-60");

function DropdownMenuShortcut({
className,
...props
}: DropdownMenuShortcutProps) {
return <span className={shortcutStyles({ className })} {...props} />;
}

DropdownMenuShortcut.displayName = "DropdownMenuShortcut";

export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};
Loading

0 comments on commit 4474195

Please sign in to comment.