From c47bf15bd93ea857a4d98b0090a878d36593e1a7 Mon Sep 17 00:00:00 2001 From: Echo Date: Thu, 30 Jul 2026 12:15:10 +0200 Subject: [PATCH] Redesign dropdown (#39991) --- .../components/dropdown/redesign.module.scss | 79 +++++++++++ .../components/dropdown/redesign.stories.tsx | 123 +++++++++++++++++ .../mastodon/components/dropdown/redesign.tsx | 125 ++++++++++++++++++ package.json | 1 + yarn.lock | 11 ++ 5 files changed, 339 insertions(+) create mode 100644 app/javascript/mastodon/components/dropdown/redesign.module.scss create mode 100644 app/javascript/mastodon/components/dropdown/redesign.stories.tsx create mode 100644 app/javascript/mastodon/components/dropdown/redesign.tsx diff --git a/app/javascript/mastodon/components/dropdown/redesign.module.scss b/app/javascript/mastodon/components/dropdown/redesign.module.scss new file mode 100644 index 00000000000..3b86410b340 --- /dev/null +++ b/app/javascript/mastodon/components/dropdown/redesign.module.scss @@ -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))); +} diff --git a/app/javascript/mastodon/components/dropdown/redesign.stories.tsx b/app/javascript/mastodon/components/dropdown/redesign.stories.tsx new file mode 100644 index 00000000000..b56e8df3dcb --- /dev/null +++ b/app/javascript/mastodon/components/dropdown/redesign.stories.tsx @@ -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, 'children'>>; + +export default meta; + +type Story = StoryObj; + +const handleMenuItemClick = action('menu item click'); + +export const Simple: Story = { + render(args) { + return ( + + + First item + + + Second item + + + ); + }, +}; + +export const Popover: Story = { + render(args) { + const [ref, setRef] = useState(null); + const [open, { onToggle, onFalse }] = useToggle(); + + return ( +
+ + + + + First item + + + Second item + + +
+ ); + }, +}; + +export const Controls: Story = { + render(args) { + const [sun, { onToggle }] = useToggle(); + return ( + + + First item + + +
+ + + {sun ? ( + + ) : ( + + )} + + +
+ ); + }, +}; diff --git a/app/javascript/mastodon/components/dropdown/redesign.tsx b/app/javascript/mastodon/components/dropdown/redesign.tsx new file mode 100644 index 00000000000..afa3553c3cc --- /dev/null +++ b/app/javascript/mastodon/components/dropdown/redesign.tsx @@ -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 = Merge< + { + as?: As; + children: React.ReactNode; + className?: string; + elevation?: 1 | 2; + }, + React.ComponentProps +>; + +export const DropdownPopover = ({ + isOpen, + onClose, + reference, + popoverElement, + container, + placement, + offset = 4, + flip, + strategy, + matchReferenceWidth, + closeOnClickOutside, + children, + ...props +}: DropdownProps & Omit) => { + const popoverProps = { + isOpen, + onClose, + reference, + popoverElement, + container, + placement, + offset, + flip, + strategy, + matchReferenceWidth, + closeOnClickOutside, + }; + return ( + + {({ props: popoverChildProps }) => ( + + {children} + + )} + + ); +}; + +export const Dropdown = ({ + as: asComp, + children, + className, + elevation = 1, + ...props +}: DropdownProps) => { + const Component = asComp ?? 'div'; + return ( + + {children} + + ); +}; + +type DropdownItemProps = Merge< + { as?: As; children: React.ReactNode; className?: string; active?: boolean }, + React.ComponentPropsWithoutRef +>; + +export const DropdownItem = ({ + active, + as: AsComp, + children, + className, + ...props +}: DropdownItemProps) => { + const Component = AsComp ?? 'div'; + return ( + + {children} + + ); +}; + +export const DropdownItemButton: React.FC< + { icon?: IconProp } & React.ComponentProps<'button'> +> = ({ icon, children, className, ...props }) => { + return ( + + {icon && } + {children} + + ); +}; diff --git a/package.json b/package.json index 745f3ae7215..921b2ea6d4a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/yarn.lock b/yarn.lock index c9f9d4f50f0..eda8c8d9e7c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"