Redesign dropdown (#39991)

This commit is contained in:
Echo
2026-07-30 12:15:10 +02:00
committed by GitHub
parent 6e40737d7a
commit c47bf15bd9
5 changed files with 339 additions and 0 deletions

View File

@@ -0,0 +1,79 @@
@use '@/styles/mastodon/mixins';
.menu {
@include mixins.elevation-1;
display: flex;
flex-direction: column;
align-items: flex-start;
padding: var(--space-2xs) 0;
border-radius: var(--radius-md);
background: var(--color-bg-primary);
overflow: hidden;
&[data-elevation='2'] {
@include mixins.elevation-2;
}
> hr {
height: 1px;
background-color: var(--color-border-primary);
margin: var(--space-2xs) var(--space-sm);
border: none;
}
}
.menuItem {
@include mixins.type-body-compact;
--cursor: pointer;
display: flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
cursor: var(--cursor);
transition:
background 200ms,
color 200ms;
label {
cursor: inherit;
font-weight: inherit;
}
&:hover {
background-color: color-mix(
in srgb,
var(--color-bg-primary) 90%,
var(--color-bg-inverted)
);
}
&:active,
.menuItemActive {
background-color: var(--color-bg-inverted);
color: var(--color-text-inverted);
}
&:has(:disabled) {
--cursor: not-allowed;
background-color: var(--color-bg-primary);
}
}
.menuItemButton {
appearance: none;
background: none;
border: none;
width: 100%;
}
.menu > hr {
height: 1px;
background-color: var(--color-border-primary);
margin: var(--space-2xs) var(--space-sm);
border: none;
width: calc(100% - (2 * var(--space-sm)));
}

View File

@@ -0,0 +1,123 @@
import { useState } from 'react';
import {
MoonIcon,
NumberCircleOneIcon,
NumberCircleTwoIcon,
SunIcon,
} from '@phosphor-icons/react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { action } from 'storybook/actions';
import { useToggle } from '@/mastodon/hooks/useToggle';
import { Button } from '../button/redesign';
import { ToggleField } from '../form_fields/redesign';
import { Icon } from '../icon';
import type { DropdownProps } from './redesign';
import {
Dropdown,
DropdownItem,
DropdownItemButton,
DropdownPopover,
} from './redesign';
const meta = {
title: 'Redesign/Dropdown',
args: {
elevation: 1,
},
argTypes: {
elevation: {
control: 'inline-radio',
options: [1, 2],
},
},
} satisfies Meta<Omit<DropdownProps<'div'>, 'children'>>;
export default meta;
type Story = StoryObj<typeof meta>;
const handleMenuItemClick = action('menu item click');
export const Simple: Story = {
render(args) {
return (
<Dropdown {...args}>
<DropdownItemButton
icon={NumberCircleOneIcon}
onClick={handleMenuItemClick}
>
First item
</DropdownItemButton>
<DropdownItemButton
icon={NumberCircleTwoIcon}
onClick={handleMenuItemClick}
>
Second item
</DropdownItemButton>
</Dropdown>
);
},
};
export const Popover: Story = {
render(args) {
const [ref, setRef] = useState<HTMLButtonElement | null>(null);
const [open, { onToggle, onFalse }] = useToggle();
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<Button ref={setRef} onClick={onToggle}>
Click to show dropdown
</Button>
<DropdownPopover
reference={ref}
isOpen={open}
onClose={onFalse}
{...args}
>
<DropdownItemButton
icon={NumberCircleOneIcon}
onClick={handleMenuItemClick}
>
First item
</DropdownItemButton>
<DropdownItemButton
icon={NumberCircleTwoIcon}
onClick={handleMenuItemClick}
>
Second item
</DropdownItemButton>
</DropdownPopover>
</div>
);
},
};
export const Controls: Story = {
render(args) {
const [sun, { onToggle }] = useToggle();
return (
<Dropdown {...args}>
<DropdownItemButton onClick={handleMenuItemClick}>
First item
</DropdownItemButton>
<hr />
<DropdownItem>
{sun ? (
<Icon id='sun' icon={SunIcon} />
) : (
<Icon id='moon' icon={MoonIcon} />
)}
<ToggleField label='Daytime toggle' size='sm' onChange={onToggle} />
</DropdownItem>
</Dropdown>
);
},
};

View File

@@ -0,0 +1,125 @@
import type React from 'react';
import classNames from 'classnames';
import type { Merge } from 'type-fest';
import { Icon } from '../icon';
import type { IconProp } from '../icon';
import type { PopoverProps } from '../popover';
import { Popover } from '../popover';
import classes from './redesign.module.scss';
export const menuItemClass = classes.menuItem;
export type DropdownProps<As extends React.ElementType> = Merge<
{
as?: As;
children: React.ReactNode;
className?: string;
elevation?: 1 | 2;
},
React.ComponentProps<As>
>;
export const DropdownPopover = <As extends React.ElementType = 'div'>({
isOpen,
onClose,
reference,
popoverElement,
container,
placement,
offset = 4,
flip,
strategy,
matchReferenceWidth,
closeOnClickOutside,
children,
...props
}: DropdownProps<As> & Omit<PopoverProps, 'children'>) => {
const popoverProps = {
isOpen,
onClose,
reference,
popoverElement,
container,
placement,
offset,
flip,
strategy,
matchReferenceWidth,
closeOnClickOutside,
};
return (
<Popover {...popoverProps}>
{({ props: popoverChildProps }) => (
<Dropdown {...props} {...popoverChildProps}>
{children}
</Dropdown>
)}
</Popover>
);
};
export const Dropdown = <As extends React.ElementType = 'div'>({
as: asComp,
children,
className,
elevation = 1,
...props
}: DropdownProps<As>) => {
const Component = asComp ?? 'div';
return (
<Component
{...props}
className={classNames(className, classes.menu)}
data-elevation={elevation}
>
{children}
</Component>
);
};
type DropdownItemProps<As extends React.ElementType> = Merge<
{ as?: As; children: React.ReactNode; className?: string; active?: boolean },
React.ComponentPropsWithoutRef<As>
>;
export const DropdownItem = <As extends React.ElementType = 'div'>({
active,
as: AsComp,
children,
className,
...props
}: DropdownItemProps<As>) => {
const Component = AsComp ?? 'div';
return (
<Component
{...props}
className={classNames(
className,
classes.menuItem,
active && classes.menuItemActive,
)}
>
{children}
</Component>
);
};
export const DropdownItemButton: React.FC<
{ icon?: IconProp } & React.ComponentProps<'button'>
> = ({ icon, children, className, ...props }) => {
return (
<DropdownItem
type='button'
{...props}
as='button'
className={classNames(className, classes.menuItemButton)}
>
{icon && <Icon id='menu' icon={icon} />}
{children}
</DropdownItem>
);
};

View File

@@ -51,6 +51,7 @@
"@gamestdio/websocket": "^0.3.2",
"@github/webauthn-json": "^2.1.1",
"@optimize-lodash/rollup-plugin": "^6.0.0",
"@phosphor-icons/react": "^2.1.10",
"@react-spring/web": "^10.1.1",
"@reduxjs/toolkit": "^2.0.1",
"@rolldown/plugin-babel": "^0.2.2",

View File

@@ -2977,6 +2977,7 @@ __metadata:
"@gamestdio/websocket": "npm:^0.3.2"
"@github/webauthn-json": "npm:^2.1.1"
"@optimize-lodash/rollup-plugin": "npm:^6.0.0"
"@phosphor-icons/react": "npm:^2.1.10"
"@react-spring/web": "npm:^10.1.1"
"@reduxjs/toolkit": "npm:^2.0.1"
"@rolldown/plugin-babel": "npm:^0.2.2"
@@ -3737,6 +3738,16 @@ __metadata:
languageName: node
linkType: hard
"@phosphor-icons/react@npm:^2.1.10":
version: 2.1.10
resolution: "@phosphor-icons/react@npm:2.1.10"
peerDependencies:
react: ">= 16.8"
react-dom: ">= 16.8"
checksum: 10c0/9a469e1bdcabcd3db02eda13142d57be250847ebbeb82b33c8900580a6a23ed4f411dbb9b9deacef726ff3fa8ffc960c8868b518e448bd2cee654cedf3fa4bda
languageName: node
linkType: hard
"@pinojs/redact@npm:^0.4.0":
version: 0.4.0
resolution: "@pinojs/redact@npm:0.4.0"