Skip to content

Commit

Permalink
feat: DropdownMenu (#1704)
Browse files Browse the repository at this point in the history
* feat(ui): #prax-160: implement DropdownMenu with sub components

* feat(ui): #prax-160: improve dropdown components, add docs

* feat(ui): #prax-160: add tests

* chore: add changeset

* fix: prettier

* fix(ui): after review
  • Loading branch information
VanishMax authored Aug 19, 2024
1 parent 9964151 commit 2788cf0
Show file tree
Hide file tree
Showing 20 changed files with 607 additions and 53 deletions.
5 changes: 5 additions & 0 deletions .changeset/honest-icons-add.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@repo/ui': minor
---

Add DropdownMenu UI component
1 change: 1 addition & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "1.0.5",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-navigation-menu": "^1.1.4",
"@radix-ui/react-popover": "^1.0.7",
Expand Down
4 changes: 4 additions & 0 deletions packages/ui/src/Button/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,13 +179,17 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
actionType = 'default',
type = 'button',
priority = 'primary',
// needed for the Radix's `asChild` prop to work correctly
// https://www.radix-ui.com/primitives/docs/guides/composition#composing-with-your-own-react-components
...props
},
ref,
) => {
const density = useDensity();

return (
<StyledButton
{...props}
{...asTransientProps({ iconOnly, density, actionType, priority })}
ref={ref}
type={type}
Expand Down
10 changes: 5 additions & 5 deletions packages/ui/src/Dialog/index.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react';

import { Dialog } from '.';
import { Button } from '../Button';
import { ComponentType, useState } from 'react';
import { ComponentType } from 'react';
import { Text } from '../Text';
import styled from 'styled-components';
import { Ban, Handshake, ThumbsUp } from 'lucide-react';
Expand Down Expand Up @@ -31,11 +31,11 @@ type Story = StoryObj<typeof Dialog>;

export const Basic: Story = {
render: function Render() {
const [isOpen, setIsOpen] = useState(false);

return (
<Dialog isOpen={isOpen} onClose={() => setIsOpen(false)}>
<Button onClick={() => setIsOpen(true)}>Open dialog</Button>
<Dialog>
<Dialog.Trigger asChild>
<Button>Open dialog</Button>
</Dialog.Trigger>

<Dialog.Content
title='This is the heading'
Expand Down
40 changes: 40 additions & 0 deletions packages/ui/src/DropdownMenu/CheckboxItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import {
CheckboxItem as RadixDropdownMenuCheckboxItem,
ItemIndicator as RadixDropdownMenuItemIndicator,
} from '@radix-ui/react-dropdown-menu';
import { ReactNode } from 'react';
import { Check } from 'lucide-react';
import { asTransientProps } from '../utils/asTransientProps.ts';
import { Text } from '../Text';
import { DropdownMenuItemBase, MenuItem } from './shared.ts';

export interface DropdownMenuCheckboxItemProps extends DropdownMenuItemBase {
children?: ReactNode;
checked?: boolean;
onChange?: (value: boolean) => void;
}

export const CheckboxItem = ({
children,
actionType = 'default',
disabled,
checked,
onChange,
}: DropdownMenuCheckboxItemProps) => {
return (
<RadixDropdownMenuCheckboxItem
checked={checked}
disabled={disabled}
asChild
onCheckedChange={onChange}
>
<MenuItem {...asTransientProps({ actionType, disabled })}>
<RadixDropdownMenuItemIndicator>
<Check />
</RadixDropdownMenuItemIndicator>

<Text small>{children}</Text>
</MenuItem>
</RadixDropdownMenuCheckboxItem>
);
};
31 changes: 31 additions & 0 deletions packages/ui/src/DropdownMenu/Content.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { ReactNode } from 'react';
import { useTheme } from 'styled-components';
import {
Content as RadixDropdownMenuContent,
Portal as RadixDropdownMenuPortal,
DropdownMenuContentProps as RadixDropdownMenuContentProps,
} from '@radix-ui/react-dropdown-menu';
import { PopoverContent } from '../utils/popover.ts';

export interface DropdownMenuContentProps {
children?: ReactNode;
side?: RadixDropdownMenuContentProps['side'];
align?: RadixDropdownMenuContentProps['align'];
}

export const Content = ({ children, side, align }: DropdownMenuContentProps) => {
const theme = useTheme();

return (
<RadixDropdownMenuPortal>
<RadixDropdownMenuContent
sideOffset={theme.spacing(1, 'number')}
side={side}
align={align}
asChild
>
<PopoverContent>{children}</PopoverContent>
</RadixDropdownMenuContent>
</RadixDropdownMenuPortal>
);
};
25 changes: 25 additions & 0 deletions packages/ui/src/DropdownMenu/Item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { ReactNode } from 'react';
import { Item as RadixDropdownMenuItem } from '@radix-ui/react-dropdown-menu';
import { asTransientProps } from '../utils/asTransientProps.ts';
import { Text } from '../Text';
import { DropdownMenuItemBase, MenuItem } from './shared.ts';

export interface DropdownMenuItemProps extends DropdownMenuItemBase {
children?: ReactNode;
onSelect?: (event: Event) => void;
}

export const Item = ({
children,
actionType = 'default',
disabled,
onSelect,
}: DropdownMenuItemProps) => {
return (
<RadixDropdownMenuItem disabled={disabled} asChild onSelect={onSelect}>
<MenuItem {...asTransientProps({ actionType, disabled })}>
<Text small>{children}</Text>
</MenuItem>
</RadixDropdownMenuItem>
);
};
16 changes: 16 additions & 0 deletions packages/ui/src/DropdownMenu/RadioGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { ReactNode } from 'react';
import { RadioGroup as RadixDropdownMenuRadioGroup } from '@radix-ui/react-dropdown-menu';

export interface DropdownMenuRadioGroupProps {
children?: ReactNode;
value?: string;
onChange?: (value: string) => void;
}

export const RadioGroup = ({ children, value, onChange }: DropdownMenuRadioGroupProps) => {
return (
<RadixDropdownMenuRadioGroup value={value} onValueChange={onChange}>
{children}
</RadixDropdownMenuRadioGroup>
);
};
33 changes: 33 additions & 0 deletions packages/ui/src/DropdownMenu/RadioItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import {
RadioItem as RadixDropdownMenuRadioItem,
ItemIndicator as RadixDropdownMenuItemIndicator,
} from '@radix-ui/react-dropdown-menu';
import { ReactNode } from 'react';
import { Check } from 'lucide-react';
import { asTransientProps } from '../utils/asTransientProps.ts';
import { Text } from '../Text';
import { DropdownMenuItemBase, MenuItem } from './shared.ts';

export interface DropdownMenuRadioItemProps extends DropdownMenuItemBase {
children?: ReactNode;
value: string;
}

export const RadioItem = ({
children,
value,
actionType = 'default',
disabled,
}: DropdownMenuRadioItemProps) => {
return (
<RadixDropdownMenuRadioItem value={value} disabled={disabled} asChild>
<MenuItem {...asTransientProps({ actionType, disabled })}>
<RadixDropdownMenuItemIndicator>
<Check />
</RadixDropdownMenuItemIndicator>

<Text small>{children}</Text>
</MenuItem>
</RadixDropdownMenuRadioItem>
);
};
90 changes: 90 additions & 0 deletions packages/ui/src/DropdownMenu/Root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { ReactNode } from 'react';
import { Root as RadixDropdownMenuRoot } from '@radix-ui/react-dropdown-menu';
import { Trigger } from './Trigger.tsx';
import { Content } from './Content.tsx';
import { RadioGroup } from './RadioGroup.tsx';
import { RadioItem } from './RadioItem.tsx';
import { CheckboxItem } from './CheckboxItem.tsx';
import { Item } from './Item.tsx';

interface ControlledDropdownMenuProps {
/**
* Whether the popover is currently open. If left `undefined`, this will be
* treated as an uncontrolled popover — that is, it will open and close based
* on user interactions rather than on state variables.
*/
isOpen: boolean;
/**
* Callback for when the user closes the popover. Should update the state
* variable being passed in via `isOpen`. If left `undefined`, users will not
* be able to close it -- that is, it will only be able to be closed programmatically
*/
onClose?: VoidFunction;
}

interface UncontrolledDropdownMenuProps {
isOpen?: undefined;
onClose?: undefined;
}

export type DropdownMenuProps = {
children?: ReactNode;
} & (ControlledDropdownMenuProps | UncontrolledDropdownMenuProps);

/**
* A dropdown menu with a set of subcomponents for composing complex menus.
*
* `<DropdownMenu>` can be controlled or uncontrolled. If `isOpen` is not provided
* but `<DropdownMenu.Trigger>` is present, it will open itself.
*
* You can nest multiple components inside the `<DropdownMenu.Content>`:
* - `<DropdownMenu.Item>` as an action button in the dropdown
* - `<DropdownMenu.RadioGroup>` with `<DropdownMenu.RadioItem>` as a group of radio buttons
* - `<DropdownMenu.CheckboxItem>` as a checkbox
*
* Example:
*
* ```tsx
* const [radioValue, setRadioValue] = useState('1');
* const [apple, setApple] = useState(false);
* const [banana, setBanana] = useState(false);
*
* <DropdownMenu>
* <DropdownMenu.Trigger>
* <Button iconOnly icon={Filter}>
* Filter
* </Button>
* </DropdownMenu.Trigger>
*
* <DropdownMenu.Content>
* <DropdownMenu.Item>Default item</DropdownMenu.Item>
* <DropdownMenu.Item actionType='destructive'>Destructive item</DropdownMenu.Item>
*
* <DropdownMenu.RadioGroup value={value} onValueChange={setValue}>
* <DropdownMenu.RadioItem value='4'>Default</DropdownMenu.RadioItem>
* <DropdownMenu.RadioItem value='5' disabled>Disabled</DropdownMenu.RadioItem>
* </DropdownMenu.RadioGroup>
*
* <DropdownMenu.CheckboxItem checked={apple} onChange={setApple}>Apple</DropdownMenu.CheckboxItem>
* <DropdownMenu.CheckboxItem checked={banana} onChange={setBanana}>Banana</DropdownMenu.CheckboxItem>
* </DropdownMenu.Content>
* </DropdownMenu>
* ```
*/
export const DropdownMenu = ({ children, onClose, isOpen }: DropdownMenuProps) => {
return (
<RadixDropdownMenuRoot
open={isOpen}
onOpenChange={onClose ? value => !value && onClose() : undefined}
>
{children}
</RadixDropdownMenuRoot>
);
};

DropdownMenu.Trigger = Trigger;
DropdownMenu.Content = Content;
DropdownMenu.RadioGroup = RadioGroup;
DropdownMenu.RadioItem = RadioItem;
DropdownMenu.CheckboxItem = CheckboxItem;
DropdownMenu.Item = Item;
10 changes: 10 additions & 0 deletions packages/ui/src/DropdownMenu/Trigger.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { ReactNode } from 'react';
import { Trigger as RadixDropdownMenuTrigger } from '@radix-ui/react-dropdown-menu';

export interface DropdownMenuTriggerProps {
children: ReactNode;
}

export const Trigger = ({ children }: DropdownMenuTriggerProps) => (
<RadixDropdownMenuTrigger asChild>{children}</RadixDropdownMenuTrigger>
);
Loading

0 comments on commit 2788cf0

Please sign in to comment.