From 8ee04ac21f3ea4f80c8fbc0df86db78f63b92b4d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Sep 2025 09:55:59 +0000 Subject: [PATCH 1/2] Initial plan From 4a3c29b01dd8cc42b2e3c417fa2b6030c80b93a9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Sep 2025 10:04:22 +0000 Subject: [PATCH 2/2] Migrate Menu components to vanilla-extract styles Co-authored-by: matthprost <15812968+matthprost@users.noreply.github.com> --- .../ui/src/components/Menu/MenuContent.tsx | 106 ++------ .../src/components/Menu/components/Group.tsx | 11 +- .../src/components/Menu/components/Item.tsx | 124 +-------- packages/ui/src/components/Menu/styles.css.ts | 254 ++++++++++++++++++ 4 files changed, 291 insertions(+), 204 deletions(-) create mode 100644 packages/ui/src/components/Menu/styles.css.ts diff --git a/packages/ui/src/components/Menu/MenuContent.tsx b/packages/ui/src/components/Menu/MenuContent.tsx index d2363a1fd9..b46eade24c 100644 --- a/packages/ui/src/components/Menu/MenuContent.tsx +++ b/packages/ui/src/components/Menu/MenuContent.tsx @@ -1,6 +1,6 @@ 'use client' -import styled from '@emotion/styled' +import { theme } from '@ultraviolet/themes' import type { ButtonHTMLAttributes, KeyboardEvent, @@ -26,79 +26,17 @@ import { Stack } from '../Stack' import { SIZES } from './constants' import { getListItem, searchChildren } from './helpers' import { DisclosureContext, useMenu } from './MenuProvider' +import { + content, + footer, + menuList, + styledPopup, + styledSearchInput, +} from './styles.css' import type { MenuProps } from './types' const SPACE_DISCLOSURE_POPUP = 24 // in px -const StyledPopup = styled(Popup, { - shouldForwardProp: prop => !['searchable'].includes(prop), -})<{ searchable: boolean }>` - background-color: ${({ theme }) => - theme.colors.other.elevation.background.raised}; - box-shadow: ${({ theme }) => - `${theme.shadows.raised[0]}, ${theme.shadows.raised[1]}`}; - padding: 0; - - &[data-has-arrow='true'] { - &::after { - border-color: ${({ theme }) => - theme.colors.other.elevation.background.raised} - transparent transparent transparent; - } - } - - min-width: ${SIZES.small}; - max-width: ${SIZES.large}; - - ${({ searchable }) => (searchable ? `min-width: 20rem` : null)}; - padding: ${({ theme }) => `${theme.space['0.25']} 0`}; - -` - -const Content = styled(Stack)` -overflow: auto; -` - -const Footer = styled(Stack)` - padding: ${({ theme }) => theme.space['1']}; -` - -const MenuList = styled(Stack, { - shouldForwardProp: prop => !['height', 'heightAvailableSpace'].includes(prop), -})<{ height: string; heightAvailableSpace: string }>` - overflow-y: auto; - overflow-x: hidden; - max-height: ${({ theme, height, heightAvailableSpace }) => - `calc(min(${height}, ${heightAvailableSpace}) - ${theme.space['0.5']})`}; - - &:after, - &:before { - border: solid transparent; - border-width: 9px; - content: ' '; - height: 0; - width: 0; - position: absolute; - pointer-events: none; - } - - &:after { - border-color: transparent; - } - &:before { - border-color: transparent; - } - background-color: ${({ theme }) => - theme.colors.other.elevation.background.raised}; - color: ${({ theme }) => theme.colors.neutral.text}; - border-radius: ${({ theme }) => theme.radii.default}; - position: relative; -` - -const StyledSearchInput = styled(SearchInput)` - padding: ${({ theme }) => theme.space['1']}; -` - export const Menu = forwardRef( ( { @@ -274,10 +212,10 @@ export const Menu = forwardRef( }, [isVisible, portalTarget, disclosureRef, placement, noShrink]) return ( - setShouldBeVisible(true)} onMouseLeave={() => setShouldBeVisible(false)} role="menu" + style={{ + maxHeight: `calc(min(${maxHeight ?? '30rem'}, ${popupMaxHeight}) - ${theme.space['0.5']})`, + padding: `${theme.space['0.25']} 0`, + }} > - + {searchable && typeof children !== 'function' ? ( - ) : null} {finalChild} - - {footer ? : null} - + + {footer ? {footer} : null} + } visible={triggerMethod === 'click' ? isVisible : shouldBeVisible} > {finalDisclosure} - + ) }, ) diff --git a/packages/ui/src/components/Menu/components/Group.tsx b/packages/ui/src/components/Menu/components/Group.tsx index 52a2f3d154..44c96f8ca7 100644 --- a/packages/ui/src/components/Menu/components/Group.tsx +++ b/packages/ui/src/components/Menu/components/Group.tsx @@ -1,15 +1,10 @@ 'use client' -import styled from '@emotion/styled' import type { ReactNode } from 'react' import { Children } from 'react' import { Stack } from '../../Stack' import { Text } from '../../Text' - -const Container = styled.span` - padding: ${({ theme }) => `${theme.space['0.5']} ${theme.space['1.5']}`}; - text-align: left; -` +import { groupContainer } from '../styles.css' type GroupProps = { label: string @@ -31,7 +26,7 @@ export const Group = ({ return ( <> - + {labelDescription || null} - + {isChildrenEmpty && emptyState ? emptyState : children} ) diff --git a/packages/ui/src/components/Menu/components/Item.tsx b/packages/ui/src/components/Menu/components/Item.tsx index 906c44195f..66e5d6b75b 100644 --- a/packages/ui/src/components/Menu/components/Item.tsx +++ b/packages/ui/src/components/Menu/components/Item.tsx @@ -1,7 +1,5 @@ 'use client' -import type { Theme } from '@emotion/react' -import styled from '@emotion/styled' import { ArrowRightIcon } from '@ultraviolet/icons' import type { KeyboardEvent, @@ -15,105 +13,10 @@ import { Stack } from '../../Stack' import { Tooltip } from '../../Tooltip' import { getListItem } from '../helpers' import { useDisclosureContext, useMenu } from '../MenuProvider' +import { itemContainer, styledItem, styledLinkItem } from '../styles.css' type MenuItemSentiment = 'neutral' | 'primary' | 'danger' -const ANIMATION_DURATION = 200 // in ms - -const itemCoreStyle = ({ - theme, - sentiment, - disabled, -}: { - theme: Theme - borderless: boolean - sentiment: MenuItemSentiment - disabled: boolean -}) => ` - display: flex; - justify-content: start; - text-align: left; - align-items: center; - min-height: ${theme.sizing['400']}; - max-height: ${theme.sizing['500']}; - font-size: ${theme.typography.bodySmall.fontSize}; - line-height: ${theme.typography.bodySmall.lineHeight}; - font-weight: inherit; - padding: ${`${theme.space['0.5']} ${theme.space['1']}`}; - border: none; - cursor: pointer; - min-width: 6.875rem; - width: 100%; - border-radius: ${theme.radii.default}; - transition: background-color ${ANIMATION_DURATION}ms, color ${ANIMATION_DURATION}ms; - - color: ${theme.colors[sentiment][disabled ? 'textDisabled' : 'text']}; - svg { - fill: ${theme.colors[sentiment][disabled ? 'textDisabled' : 'text']}; - } - - ${ - disabled - ? ` - cursor: not-allowed; - ` - : ` - &:hover, - &:focus-visible, &[data-active='true'] { - background-color: ${theme.colors[sentiment].backgroundHover}; - color: ${theme.colors[sentiment].textHover}; - svg { - fill: ${theme.colors[sentiment].textHover}; - } - }` - } -` - -const Container = styled('div', { - shouldForwardProp: prop => !['borderless'].includes(prop), -})<{ borderless: boolean }>` - ${({ theme, borderless }) => - borderless - ? '' - : `border-bottom: 1px solid ${theme.colors.neutral.border};`} - padding: ${({ theme, borderless }) => - `${borderless ? theme.space['0.25'] : theme.space['0.5']} ${ - theme.space['0.5'] - }`}; - &:last-child { - border: none; - } - width: 100%; -` - -const StyledItem = styled('button', { - shouldForwardProp: prop => !['borderless', 'sentiment'].includes(prop), -})<{ - borderless: boolean - disabled: boolean - sentiment: MenuItemSentiment -}>` - ${({ theme, borderless, sentiment, disabled }) => - itemCoreStyle({ borderless, disabled, sentiment, theme })} - background: none; -` - -const StyledLinkItem = styled('a', { - shouldForwardProp: prop => !['borderless', 'sentiment'].includes(prop), -})<{ - borderless: boolean - disabled: boolean - sentiment: MenuItemSentiment -}>` - ${({ theme, borderless, sentiment, disabled }) => - itemCoreStyle({ borderless, disabled, sentiment, theme })} - text-decoration: none; - - &:focus { - text-decoration: none; - } -` - type ItemProps = { href?: HTMLAnchorElement['href'] target?: HTMLAnchorElement['target'] @@ -221,23 +124,20 @@ const Item = forwardRef( if (href && !disabled) { return ( - +
- } rel={rel} role="menuitem" - sentiment={sentiment} target={target} > {isDisclosure ? ( @@ -252,18 +152,17 @@ const Item = forwardRef( ) : ( children )} - + - +
) } return ( - +
- ( onKeyDown={handleKeyDown} ref={ref as Ref} role="menuitem" - sentiment={sentiment} type="button" > {isDisclosure ? ( @@ -293,9 +191,9 @@ const Item = forwardRef( ) : ( children )} - + - +
) }, ) diff --git a/packages/ui/src/components/Menu/styles.css.ts b/packages/ui/src/components/Menu/styles.css.ts new file mode 100644 index 0000000000..cd0d352b8b --- /dev/null +++ b/packages/ui/src/components/Menu/styles.css.ts @@ -0,0 +1,254 @@ +import { theme } from '@ultraviolet/themes' +import { style } from '@vanilla-extract/css' +import type { RecipeVariants } from '@vanilla-extract/recipes' +import { recipe } from '@vanilla-extract/recipes' +import { SIZES } from './constants' + +const ANIMATION_DURATION = 200 // in ms + +// MenuContent styles +export const styledPopup = recipe({ + base: { + backgroundColor: theme.colors.other.elevation.background.raised, + boxShadow: `${theme.shadows.raised[0]}, ${theme.shadows.raised[1]}`, + padding: '0', + minWidth: SIZES.small, + maxWidth: SIZES.large, + borderRadius: theme.radii.default, + selectors: { + '&[data-has-arrow="true"]::after': { + borderColor: `${theme.colors.other.elevation.background.raised} transparent transparent transparent`, + }, + }, + }, + variants: { + searchable: { + true: { + minWidth: '20rem', + }, + false: {}, + }, + }, + defaultVariants: { + searchable: false, + }, +}) + +export const content = style({ + overflow: 'auto', +}) + +export const footer = style({ + padding: theme.space['1'], +}) + +export const menuList = recipe({ + base: { + overflowY: 'auto', + overflowX: 'hidden', + backgroundColor: theme.colors.other.elevation.background.raised, + color: theme.colors.neutral.text, + borderRadius: theme.radii.default, + position: 'relative', + selectors: { + '&::after, &::before': { + border: 'solid transparent', + borderWidth: '9px', + content: '" "', + height: 0, + width: 0, + position: 'absolute', + pointerEvents: 'none', + }, + '&::after': { + borderColor: 'transparent', + }, + '&::before': { + borderColor: 'transparent', + }, + }, + }, + variants: { + height: { + default: {}, + }, + }, + defaultVariants: { + height: 'default', + }, +}) + +export const styledSearchInput = style({ + padding: theme.space['1'], +}) + +// Item styles +export const itemContainer = recipe({ + base: { + width: '100%', + selectors: { + '&:last-child': { + border: 'none', + }, + }, + }, + variants: { + borderless: { + true: { + padding: `${theme.space['0.25']} ${theme.space['0.5']}`, + }, + false: { + borderBottom: `1px solid ${theme.colors.neutral.border}`, + padding: `${theme.space['0.5']} ${theme.space['0.5']}`, + }, + }, + }, + defaultVariants: { + borderless: false, + }, +}) + +const getItemStyle = (sentiment: 'neutral' | 'primary' | 'danger', disabled: boolean) => ({ + display: 'flex', + justifyContent: 'start', + textAlign: 'left' as const, + alignItems: 'center', + minHeight: theme.sizing['400'], + maxHeight: theme.sizing['500'], + fontSize: theme.typography.bodySmall.fontSize, + lineHeight: theme.typography.bodySmall.lineHeight, + fontWeight: 'inherit', + padding: `${theme.space['0.5']} ${theme.space['1']}`, + border: 'none', + cursor: disabled ? 'not-allowed' : 'pointer', + minWidth: '6.875rem', + width: '100%', + borderRadius: theme.radii.default, + transition: `background-color ${ANIMATION_DURATION}ms, color ${ANIMATION_DURATION}ms`, + color: theme.colors[sentiment][disabled ? 'textDisabled' : 'text'], + selectors: { + 'svg': { + fill: theme.colors[sentiment][disabled ? 'textDisabled' : 'text'], + }, + ...(disabled ? {} : { + '&:hover, &:focus-visible, &[data-active="true"]': { + backgroundColor: theme.colors[sentiment].backgroundHover, + color: theme.colors[sentiment].textHover, + }, + '&:hover svg, &:focus-visible svg, &[data-active="true"] svg': { + fill: theme.colors[sentiment].textHover, + }, + }), + }, +}) + +export const styledItem = recipe({ + base: { + background: 'none', + }, + variants: { + sentiment: { + neutral: {}, + primary: {}, + danger: {}, + }, + disabled: { + true: {}, + false: {}, + }, + }, + compoundVariants: [ + { + variants: { sentiment: 'neutral', disabled: false }, + style: getItemStyle('neutral', false), + }, + { + variants: { sentiment: 'neutral', disabled: true }, + style: getItemStyle('neutral', true), + }, + { + variants: { sentiment: 'primary', disabled: false }, + style: getItemStyle('primary', false), + }, + { + variants: { sentiment: 'primary', disabled: true }, + style: getItemStyle('primary', true), + }, + { + variants: { sentiment: 'danger', disabled: false }, + style: getItemStyle('danger', false), + }, + { + variants: { sentiment: 'danger', disabled: true }, + style: getItemStyle('danger', true), + }, + ], + defaultVariants: { + sentiment: 'neutral', + disabled: false, + }, +}) + +export const styledLinkItem = recipe({ + base: { + textDecoration: 'none', + selectors: { + '&:focus': { + textDecoration: 'none', + }, + }, + }, + variants: { + sentiment: { + neutral: {}, + primary: {}, + danger: {}, + }, + disabled: { + true: {}, + false: {}, + }, + }, + compoundVariants: [ + { + variants: { sentiment: 'neutral', disabled: false }, + style: getItemStyle('neutral', false), + }, + { + variants: { sentiment: 'neutral', disabled: true }, + style: getItemStyle('neutral', true), + }, + { + variants: { sentiment: 'primary', disabled: false }, + style: getItemStyle('primary', false), + }, + { + variants: { sentiment: 'primary', disabled: true }, + style: getItemStyle('primary', true), + }, + { + variants: { sentiment: 'danger', disabled: false }, + style: getItemStyle('danger', false), + }, + { + variants: { sentiment: 'danger', disabled: true }, + style: getItemStyle('danger', true), + }, + ], + defaultVariants: { + sentiment: 'neutral', + disabled: false, + }, +}) + +// Group styles +export const groupContainer = style({ + padding: `${theme.space['0.5']} ${theme.space['1.5']}`, + textAlign: 'left', +}) + +export type MenuContentVariants = RecipeVariants +export type MenuListVariants = RecipeVariants +export type ItemContainerVariants = RecipeVariants +export type ItemVariants = RecipeVariants +export type LinkItemVariants = RecipeVariants \ No newline at end of file