From d21d35904dbaad8a74f2fc2b42b1489da22b906b Mon Sep 17 00:00:00 2001 From: lisa Date: Tue, 14 Oct 2025 15:01:44 +0200 Subject: [PATCH] refactor: menu vanilla extract --- .changeset/four-goats-smash.md | 5 + .../ui/src/components/Menu/MenuContent.tsx | 112 +---- .../src/components/Menu/components/Group.tsx | 11 +- .../src/components/Menu/components/Item.tsx | 130 +---- packages/ui/src/components/Menu/styles.css.ts | 227 +++++++++ .../__snapshots__/index.test.tsx.snap | 451 ++++-------------- 6 files changed, 378 insertions(+), 558 deletions(-) create mode 100644 .changeset/four-goats-smash.md create mode 100644 packages/ui/src/components/Menu/styles.css.ts diff --git a/.changeset/four-goats-smash.md b/.changeset/four-goats-smash.md new file mode 100644 index 0000000000..d4c98fe282 --- /dev/null +++ b/.changeset/four-goats-smash.md @@ -0,0 +1,5 @@ +--- +"@ultraviolet/ui": minor +--- + +Refactor component `Menu` to usa vanilla extract instead of Emotion diff --git a/packages/ui/src/components/Menu/MenuContent.tsx b/packages/ui/src/components/Menu/MenuContent.tsx index d2363a1fd9..9f8cbc7ab5 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 { assignInlineVars } from '@vanilla-extract/dynamic' import type { ButtonHTMLAttributes, KeyboardEvent, @@ -23,82 +23,21 @@ import { import { Popup } from '../Popup' import { SearchInput } from '../SearchInput' import { Stack } from '../Stack' -import { SIZES } from './constants' import { getListItem, searchChildren } from './helpers' import { DisclosureContext, useMenu } from './MenuProvider' +import { + heightAvailableSpace, + heightMenu, + menu, + menuContent, + menuFooter, + menuList, + menuSearchInput, +} 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,17 +213,16 @@ export const Menu = forwardRef( }, [isVisible, portalTarget, disclosureRef, placement, noShrink]) return ( - { setIsVisible(false) setLocalChild(null) @@ -298,38 +236,40 @@ export const Menu = forwardRef( portalTarget={portalTarget} ref={menuRef} role="dialog" - searchable={searchable} tabIndex={-1} text={ - setShouldBeVisible(true)} onMouseLeave={() => setShouldBeVisible(false)} role="menu" + style={assignInlineVars({ + [heightMenu]: maxHeight ?? '30rem', + [heightAvailableSpace]: popupMaxHeight, + })} > - + {searchable && typeof children !== 'function' ? ( - ) : null} {finalChild} - - {footer ?
{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..33beef5def 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 { menuGroup } 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..e5bd170d42 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 { menuItem, menuItemContainer } 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,23 @@ const Item = forwardRef( if (href && !disabled) { return ( - +
- } rel={rel} role="menuitem" - sentiment={sentiment} target={target} > {isDisclosure ? ( @@ -252,18 +155,20 @@ const Item = forwardRef( ) : ( children )} - + - +
) } return ( - +
- ( onKeyDown={handleKeyDown} ref={ref as Ref} role="menuitem" - sentiment={sentiment} type="button" > {isDisclosure ? ( @@ -293,9 +197,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..65d5d3cf79 --- /dev/null +++ b/packages/ui/src/components/Menu/styles.css.ts @@ -0,0 +1,227 @@ +import { theme } from '@ultraviolet/themes' +import { recipe } from '@vanilla-extract/recipes' +import { SIZES } from './constants' +import { createVar, globalStyle, style } from '@vanilla-extract/css' + +export const heightMenu = createVar() +export const heightAvailableSpace = createVar() + +const ANIMATION_DURATION = 200 // in ms +const ITEM_SENTIMENT = ['neutral', 'danger', 'primary'] as const + +function makeItemStyle( + sentiment: keyof typeof theme.colors, + disabled: boolean, +) { + const color = theme.colors[sentiment] as typeof theme.colors.neutral + + const base = { + color: color[disabled ? 'textDisabled' : 'text'], + } + + if (!disabled) { + return { + ...base, + selectors: { + "&:hover, &:focus-visible, &[data-active='true']": { + backgroundColor: color.backgroundHover, + color: color.textHover, + }, + }, + } + } + + return base +} +export const menu = recipe({ + base: { + backgroundColor: theme.colors.other.elevation.background.raised, + boxShadow: `${theme.shadows.raised[0]}, ${theme.shadows.raised[1]}`, + padding: `${theme.space['0.25']} 0`, + minWidth: SIZES.small, + maxWidth: SIZES.large, + }, + variants: { + arrow: { + true: { + selectors: { + '&::after': { + borderColor: `${theme.colors.other.elevation.background.raised} transparent transparent transparent`, + }, + }, + }, + }, + searchable: { + true: { + minWidth: '20rem', + }, + }, + }, + defaultVariants: { + arrow: false, + searchable: false, + }, +}) + +export const menuContent = style({ overflow: 'auto' }) + +export const menuFooter = style({ padding: theme.space[1] }) + +export const menuList = style({ + overflowX: 'hidden', + overflowY: 'auto', + maxHeight: `calc(min(${heightMenu}, ${heightAvailableSpace}) - ${theme.space['0.5']})`, + backgroundColor: theme.colors.other.elevation.background.raised, + color: theme.colors.neutral.text, + position: 'relative', + selectors: { + '&:after, &:before': { + border: 'solid transparent', + borderWidth: 9, + content: ' ', + height: 0, + width: 0, + position: 'absolute', + pointerEvents: 'none', + }, + }, +}) + +export const menuSearchInput = style({ padding: theme.space[1] }) + +export const menuGroup = style({ + padding: `${theme.space['0.5']} ${theme.space['1.5']}`, + textAlign: 'left', +}) + +export const menuItemContainer = recipe({ + base: { + selectors: { + '&:last-child': { + border: 'none', + }, + }, + width: '100%', + }, + variants: { + borderless: { + false: { + borderBottom: `1px solid ${theme.colors.neutral.border}`, + padding: `${theme.space['0.5']} ${theme.space['0.5']}`, + }, + true: { + padding: `${theme.space['0.25']} ${theme.space['0.5']}`, + }, + }, + }, + defaultVariants: { + borderless: false, + }, +}) + +export const menuItem = recipe({ + base: { + background: 'none', + textDecoration: 'none', + display: 'flex', + justifyContent: 'start', + textAlign: 'left', + 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: 'pointer', + minWidth: '6.875rem', + width: '100%', + borderRadius: theme.radii.default, + transition: `background-color ${ANIMATION_DURATION}ms, color ${ANIMATION_DURATION}ms`, + selectors: { + '&:focus': { + textDecoration: 'none', + }, + }, + }, + variants: { + borderless: { + true: {}, + }, + disabled: { + true: { + cursor: 'not-allowed', + }, + }, + sentiment: Object.fromEntries( + ITEM_SENTIMENT.map(sentiment => [sentiment, {}]), + ), + }, + compoundVariants: [ + ...ITEM_SENTIMENT.map(sentiment => ({ + variants: { + sentiment, + disabled: false, + }, + style: makeItemStyle(sentiment, false), + })), + ...ITEM_SENTIMENT.map(sentiment => ({ + variants: { + sentiment, + disabled: true, + }, + style: makeItemStyle(sentiment, true), + })), + ], + defaultVariants: { + borderless: false, + disabled: false, + sentiment: 'neutral', + }, +}) + +ITEM_SENTIMENT.map(sentiment => + globalStyle( + `${menuItem({ borderless: false, disabled: false, sentiment })} > svg`, + { + fill: theme.colors[sentiment].text, + }, + ), +) + +ITEM_SENTIMENT.map(sentiment => + globalStyle( + `${menuItem({ borderless: false, disabled: true, sentiment })} > svg`, + { + fill: theme.colors[sentiment].textDisabled, + }, + ), +) + +ITEM_SENTIMENT.map(sentiment => + globalStyle( + `${menuItem({ borderless: false, disabled: false, sentiment })}:hover, ${menuItem({ borderless: false, disabled: false, sentiment })}:focus-visible, ${menuItem({ borderless: false, disabled: false, sentiment })}[data-active="true"] > svg, ${menuItem({ borderless: true, disabled: false, sentiment })}:hover, ${menuItem({ borderless: true, disabled: false, sentiment })}:focus-visible, ${menuItem({ borderless: true, disabled: false, sentiment })}[data-active="true"] > svg`, + { + fill: theme.colors[sentiment].textHover, + }, + ), +) + +ITEM_SENTIMENT.map(sentiment => + globalStyle( + `${menuItem({ borderless: true, disabled: false, sentiment })} > svg`, + { + fill: theme.colors[sentiment].text, + }, + ), +) + +ITEM_SENTIMENT.map(sentiment => + globalStyle( + `${menuItem({ borderless: true, disabled: true, sentiment })} > svg`, + { + fill: theme.colors[sentiment].textDisabled, + }, + ), +) diff --git a/packages/ui/src/components/Tabs/__tests__/__snapshots__/index.test.tsx.snap b/packages/ui/src/components/Tabs/__tests__/__snapshots__/index.test.tsx.snap index 05dc797cd7..ccfe9650fb 100644 --- a/packages/ui/src/components/Tabs/__tests__/__snapshots__/index.test.tsx.snap +++ b/packages/ui/src/components/Tabs/__tests__/__snapshots__/index.test.tsx.snap @@ -1853,136 +1853,33 @@ exports[`tabs > renders correctly with Tabs with prop 1`] = ` - .emotion-0 { - background-color: #ffffff; - box-shadow: 0px 4px 8px 0px #22263829,0px 8px 24px 0px #2226383d; - padding: 0; - min-width: 11.25rem; - max-width: 23.75rem; - padding: 0.125rem 0; -} - -.emotion-0[data-has-arrow='true']::after { - border-color: #ffffff transparent transparent transparent; -} - -.emotion-2 { - overflow-y: auto; - overflow-x: hidden; - max-height: calc(min(30rem, -24px) - 0.25rem); - background-color: #ffffff; - color: #3f4250; - border-radius: 0.25rem; - position: relative; -} - -.emotion-2:after, -.emotion-2:before { - border: solid transparent; - border-width: 9px; - content: ' '; - height: 0; - width: 0; - position: absolute; - pointer-events: none; -} - -.emotion-2:after { - border-color: transparent; -} - -.emotion-2:before { - border-color: transparent; -} - -.emotion-4 { - overflow: auto; -} - -.emotion-6 { - border-bottom: 1px solid #d9dadd; - padding: 0.25rem 0.25rem; - width: 100%; -} - -.emotion-6:last-child { - border: none; -} - -.emotion-9 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-box-pack: start; - -ms-flex-pack: start; - -webkit-justify-content: start; - justify-content: start; - text-align: left; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - min-height: 2rem; - max-height: 2.5rem; - font-size: 0.875rem; - line-height: 1.25rem; - font-weight: inherit; - padding: 0.25rem 0.5rem; - border: none; - cursor: pointer; - min-width: 6.875rem; - width: 100%; - border-radius: 0.25rem; - -webkit-transition: background-color 200ms,color 200ms; - transition: background-color 200ms,color 200ms; - color: #3f4250; - background: none; -} - -.emotion-9 svg { - fill: #3f4250; -} - -.emotion-9:hover, -.emotion-9:focus-visible, -.emotion-9[data-active='true'] { - background-color: #e9eaeb; - color: #222638; -} - -.emotion-9:hover svg, -.emotion-9:focus-visible svg, -.emotion-9[data-active='true'] svg { - fill: #222638; -} - -.emotion-9[aria-selected='true'] { + .emotion-0[aria-selected='true'] { color: #641cb3; }