Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: DropdownMenu #1704

Merged
merged 6 commits into from
Aug 19, 2024
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
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
Loading